update wordpress plugin from github private repository

Cómo actualizar un plugin de WordPress desde un repositorio privado de GitHub

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.

¿Te ha resultado útil esta información? 🍺

Si este post te ha resuelto un problema, invítame a un café o a una cerveza. Con este pequeño gesto me animas a seguir escribiendo.

Comentarios

11 comentarios en Cómo actualizar un plugin de WordPress desde un repositorio privado de GitHub

  1. ¡Gran artículo, Pablo! La verdad que es un tema interesante aquel cuando te mandan plugins y no sabes si «puedes» o «debes» subirlo al repo de WordPress.

    ¡Gran aporte, como siempre!

  2. Excelente articulo, quería hacerte una pregunta, ¿como sería una hoja de ruta para aprender wordpress según tu experiencia?, estoy comenzando con el desarrollo en wordpress y ya estoy creando temas hijos, lo que no sé es si tengo buenas practicas o si realmente estoy creando algo de calidad.

    saludos desde Chile

  3. Hola, tengo una consulta.

    Hay alguna forma de verificar que conecta con github y está bien el trabajo?.
    he creado el plugin, instalado en wordpress y subido repositorio como privado en GitHub.

    Pensé que me apareceria el clásico enlace de actualizaciones automáticas a la derecha del plugin, como puedo hacer una demo de actualización para ver si sincroniza y actualiza en wordpress?.

    Muchas, Muchas Gracias

    1. Hola Manuel

      De hecho debería funcionar así, igual que un plugin del repositorio de WP. Si hay una versión nueva, te aparece el enlace de descarga y si lo actualizas, la manera de verificar es que tienes la última versión

  4. Hola Pablo, muy bueno, me ha servido!. Lo único que cambié fue para leer los Tags y no los Releases, porque sino por cada actualización tenia que entrar en Github y crear un Release, no se si se puede hacer desde local, en cambio los Tags si, los toma directo.
    Y otra cosa recomiendo crear una cuenta en Github para el cliente y tener solo ese repo ya que con el token y el usuario cualquiera puede entrar a todos nuestros repos privados. Eso es lo que pude sacar en conclusión, si llegara a estar equivocado avisame. Gracias!

  5. Buenas Pablo, estoy teniendo un problema a la hora de implementarlo y es que tengo dos plugins con este mismo sistema, pero a la hora de actualizar el ultimo que hayas instalado peta, parece que obtiene los datos del otro plugin y al no reconocerlos correctamente no funciona. ¿Como se podría hacer para corregir este error?

    1. Hola Alejandro.

      Es extraño esto que comentas, ya que en cada plugin deberías configurar la URL del repositorio donde está, es decir, son dos repositorios independientes y cada uno debería actualizarse si hay nuevos tags. Lo siento, sin ver el código, no puedo hacerme una idea

      1. Me pasa lo mismo, cambie los define por un array con opciones (para que los defines no se pisen unos a otros entre plugin con el mismo sistema), pero se matan entre ellos y da error (se muere WP, error critico).
        Si solo lo uso en un plugin, funciona bien, pero al colocar el sistema en otro, se mueren, como que trata de usar los datos del ultimo con los demas.
        code: https://pastebin.com/sPZjxhPK

    2. He recibido un pull request en el repo de GitHub que solucionaba este problema, era en el método after_install, la respuesta debía ser un bool y no un array

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *:

  • El fin del tratamiento es únicamente la moderación de comentarios para evitar spam
  • La legitimación es tu consentimiento al comentar
  • No se comunicará ningún dato a terceros salvo por obligación legal
  • Tienes derecho al acceso, rectificación y eliminación de los comentarios