Es probable que en alguna ocasión te hayan pedido desarrollar un plugin para WordPress. Y puede ser (no es raro de hecho), que tu cliente no quiera que subas el plugin que has desarrollado al repositorio oficial de WordPress. Por el motivo que sea.
Pero claro, no tenerlo en el repo oficial, significa que tendrías que mantenerle el código y subir manualmente cualquier cambio, modificación, corrección, evolutivo, etc… que te pida.
Buscando un procedimiento más estándar y óptimo, podrías llegar a versionar tu plugin utilizando GitHub y taggearlo con SemVer (aquí tienes más info sobre las tags y Semantic Version).
Llegados a este punto, es probable que te pida que el repositorio sea privado, ya que es un desarrollo ad-hoc para él, y no quiera que su desarrollo esté disponible y abierto.
Bien, pues sólo tienes que añadir una clase a tu plugin para que conecte con tu repositorio de GitHub. Te saltará la advertencia de que hay una versión nueva de tu plugin y podrás actualizar desde el dashboard de WordPress como si un plugin del repositorio oficial se tratase.
A continuación muestro las líneas que tienes que incluir en la raíz de tu plugin y la clase que se encarga de buscar si hay una versión nueva. Después explicaré en detalle qué hace cada método.
Lo primero es que añadas estas líneas en la raíz de tu plugin:
<?php
/**
* Plugin Name: Your private plugin name
* Plugin URI: https://example.com/plugins/the-basics/
* Description: GitHub Private Plugin Test
* Version: 1.0.0
* Requires at least: 5.8
* Tested up to: 5.8.2
* Requires PHP: 7.2
* Author: Your name
* Author URI: Your URL
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: your-textdomain
* Domain Path: /languages.
*/
defined('ABSPATH') || die('No script kiddies please!');
if (is_admin()) {
define('GH_REQUEST_URI', 'https://api.github.com/repos/%s/%s/releases');
define('GHPU_USERNAME', 'YOUR_GITHUB_USERNAME');
define('GHPU_REPOSITORY', 'YOUR_GITHUB_REPOSITORY_NAME');
define('GHPU_AUTH_TOKEN', 'YOUR_GITHUB_TOKEN');
include_once plugin_dir_path(__FILE__) . '/gh-plugin-updater/GhPluginUpdater.php';
$updater = new GhPluginUpdater(__FILE__);
$updater->init();
}
Tendrás que desarrollar tu plugin siguiendo las especificaciones de WordPress, y después añade un condicional para que se ejecute sólo si estás en el dashboard. Aquí simplemente inicializa las constantes con los datos de tu cuenta de GitHub: nombre de usuario, nombre del repositorio y token (esto último lo puedes crear desde tu cuenta => Settings => Developer Settings => Personal access tokens => Generate new token).
A continuación incluye la clase que veremos a continuación, dentro de la carpeta que quieras dentro de tu plugin, como por ejemplo /gh-plugin-updater/GhPluginUpdater.php. Y por último instancia la clase pasándole el path del plugin y llama al método init() para lanzar los hooks necesarios.
La clase en cuestión es la siguiente:
<?php
declare(strict_types=1);
/**
* Update WordPress plugin from GitHub Private Repository.
*/
class GhPluginUpdater
{
private $file;
private $plugin_data;
private $basename;
private $active = false;
private $github_response;
public function __construct($file)
{
$this->file = $file;
$this->basename = plugin_basename($this->file);
}
/**
* Init GitHub Plugin Updater.
*/
public function init(): void
{
add_filter('pre_set_site_transient_update_plugins', [$this, 'modify_transient'], 10, 1);
add_filter('http_request_args', [$this, 'set_header_token'], 10, 2);
add_filter('plugins_api', [$this, 'plugin_popup'], 10, 3);
add_filter('upgrader_post_install', [$this, 'after_install'], 10, 3);
}
/**
* If new version exists, update transient with GitHub info.
*
* @param object $transient Transient object with plugins information.
*/
public function modify_transient(object $transient): object
{
if (! property_exists($transient, 'checked')) {
return $transient;
}
$this->get_repository_info();
$this->get_plugin_data();
if (version_compare($this->github_response['tag_name'], $transient->checked[$this->basename], 'gt')) {
$plugin = [
'url' => $this->plugin_data['PluginURI'],
'slug' => current(explode('/', $this->basename)),
'package' => $this->github_response['zipball_url'],
'new_version' => $this->github_response['tag_name'],
];
$transient->response[$this->basename] = (object) $plugin;
}
return $transient;
}
/**
* Complete details of new plugin version on popup.
*
* @param array|false|object $result The result object or array. Default false.
* @param string $action The type of information being requested from the Plugin Installation API.
* @param object $args Plugin API arguments.
*/
public function plugin_popup(bool $result, string $action, object $args)
{
if ('plugin_information' !== $action || empty($args->slug)) {
return false;
}
if ($args->slug == current(explode('/', $this->basename))) {
$this->get_repository_info();
$this->get_plugin_data();
$plugin = [
'name' => $this->plugin_data['Name'],
'slug' => $this->basename,
'requires' => $this->plugin_data['RequiresWP'],
'tested' => $this->plugin_data['TestedUpTo'],
'version' => $this->github_response['tag_name'],
'author' => $this->plugin_data['AuthorName'],
'author_profile' => $this->plugin_data['AuthorURI'],
'last_updated' => $this->github_response['published_at'],
'homepage' => $this->plugin_data['PluginURI'],
'short_description' => $this->plugin_data['Description'],
'sections' => [
'Description' => $this->plugin_data['Description'],
'Updates' => $this->github_response['body'],
],
'download_link' => $this->github_response['zipball_url'],
];
return (object) $plugin;
}
return $result;
}
/**
* Active plugin after install new version.
*
* @param bool $response Installation response.
* @param array $hook_extra Extra arguments passed to hooked filters.
* @param array $result Installation result data.
*/
public function after_install(bool $response, array $hook_extra, array $result): array
{
global $wp_filesystem;
$install_directory = plugin_dir_path($this->file);
$wp_filesystem->move($result['destination'], $install_directory);
$result['destination'] = $install_directory;
if ($this->active) {
activate_plugin($this->basename);
}
return $response;
}
/**
* GitHub access_token param was deprecated. We need to set header with token for requests.
*
* @param array $args HTTP request arguments.
* @param string $url The request URL.
*/
public function set_header_token(array $parsed_args, string $url): array
{
$parsed_url = parse_url($url);
if ('api.github.com' === ($parsed_url['host'] ?? null) && isset($parsed_url['query'])) {
parse_str($parsed_url['query'], $query);
if (isset($query['access_token'])) {
$parsed_args['headers']['Authorization'] = 'token ' . $query['access_token'];
$this->active = is_plugin_active($this->basename);
}
}
return $parsed_args;
}
/**
* Gets repository data from GitHub.
*/
private function get_repository_info(): void
{
if (null !== $this->github_response) {
return;
}
$args = [
'method' => 'GET',
'timeout' => 5,
'redirection' => 5,
'httpversion' => '1.0',
'headers' => [
'Authorization' => 'token ' . GHPU_AUTH_TOKEN,
],
'sslverify' => true,
];
$request_uri = sprintf(GH_REQUEST_URI, GHPU_USERNAME, GHPU_REPOSITORY);
$request = wp_remote_get($request_uri, $args);
$response = json_decode(wp_remote_retrieve_body($request), true);
if (is_array($response)) {
$response = current($response);
}
if (GHPU_AUTH_TOKEN) {
$response['zipball_url'] = add_query_arg('access_token', GHPU_AUTH_TOKEN, $response['zipball_url']);
}
$this->github_response = $response;
}
/**
* Gets plugin data.
*/
private function get_plugin_data(): void
{
if (null !== $this->plugin_data) {
return;
}
$this->plugin_data = get_plugin_data($this->file);
}
}
Tienes también esta clase en un repositorio de GitHub. A continuación vamos a explicar paso por paso qué hace cada método.
Método init
Este método se ejecuta nada más instanciar la clase, y lo único que hace es lanzar varios hooks:
- pre_set_site_transient_update_plugins, para actuar en el punto donde WordPress comprueba si hay nuevas versiones o no.
- http_request_args, para modificar la petición HTTP GET que se hace a GitHub e incluir el token en las cabeceras de la misma.
- plugins_api, para actuar antes de mostrar la información del plugin en el modal de «Revisa los detalles de la versión X.Y.Z».
- upgrader_post_install, para reactivar el plugin tras la instalación de la nueva versión.
Método get_repository_info
Este método conecta con GitHub utilizando las credenciales que hemos establecido anteriormente y obtiene la información del repositorio. GitHub devuelve en una de las propiedades (zipball_url) del objeto la URL con el zip de descarga.
Si el repo es público no hay problema, pero si es privado necesitarás añadir a esa URL un parámetro access_token con el valor que has establecido anteriormente.
Método modify_transient
Este método se engancha al hook pre_set_site_transient_update_plugins, que se utiliza para filtrar/modificar el valor de un transient. En este caso el que contiene información sobre los plugins.
Lo que hacemos en este método es por un lado obtener la información de nuestro repositorio de GitHub (get_repository_info()), y por otro obtener la información de nuestro plugin actualmente en nuestro WordPress (get_plugin_data()).
En este punto es fundamental recalcar la importancia de que utilices una nomenclatura clara y precisa para cada versión nueva de tu plugin. Como te recomendaba antes, Semantic Version es una opción óptima y precisa.
Comparamos las dos versiones, y si la versión de GitHub es mayor (‘gt’ => greater than) que la versión que tienes actualmente instalada, añadimos al transient la información sobre la nueva versión: url, slug, package y new_version.
Método set_header_token
Aquí tenemos que hacer un workaround. Hasta Septiembre de 2021 GitHub permitía hacer peticiones pasando por parámetro el access_token. Desde entonces es necesario que establezcas el token en las cabeceras de la petición.
Y lo malo es que no podemos establecer las cabeceras de una petición en ningún sitio antes del hook http_request_args, y este hook WordPress lo utiliza bastante. Por lo tanto es necesario hacer una serie de comprobaciones primero, para evitar añadir la cabecera en peticiones que no tienen nada que ver con la actualización del plugin desde GitHub.
Lo primero es comprobar si la URL es de GitHub (api.github.com) y si tenemos parámetros en esa URL. En caso de existir parámetros, comprobaremos que exite el parámetro access_token (recuerda que lo añadimos antes en el método get_repository_info()). En caso afirmativo, añadiremos la cabecera con el token.
En este punto comprobaremos también si el plugin está activo o no, para cuando se ejecute la descarga activarlo o no.
De este modo, cuando nos aparezca el mensaje de «Hay disponible una nueva versión de YOUR_PLUGIN. Revisa los detalles de la versión X.Y.Z», y hagamos click en «actualízalo ahora», se hará la petición al repositorio privado de GitHub con el token en la cabecera, y el instalador de WordPress podrá descargar el zip de la nueva versión.
Método after_install
Por último tenemos este método que se engancha al hook upgrader_post_install, que se ejecuta después de haber actualizado un plugin.
El nombre del zip será diferente al nombre de tu plugin, por lo que movemos la actualización a la misma carpeta de nuestro plugin. Y finalmente, lo activamos si estaba activo antes de iniciar la descarga de la nueva versión.
Método plugin_popup
Este método se utiliza en el hook plugins_api, para filtrar la información que aparece en el popup que aparece cuando haces click en el enlace de «Revisar los detalles de la versión X.Y.Z».
Aquí simplemente añadimos información obtenida del repositorio de GitHub.
Consideraciones finales
Simplemente estableciendo tus credenciales en el fichero principal de tu plugin e incluyendo esta clase, puedes desarrollar y evolucionar tu plugin desde un repositorio privado de GitHub.
De este modo puedes ofrecer a tu cliente una experiencia similar al repositorio oficial de WordPress, permitiéndole actualizar el plugin a golpe de click y teniendo su código privado y protegido.
He añadido el código de esta clase a GitHub, te animo a sugerir mejoras a través de discussions y/o pull requests.