12–19 minutos

de lectura

Cómo actualizar automáticamente un plugin de WordPress desde GitHub

Cuando un plugin personalizado está instalado en varios sitios, actualizarlo manualmente mediante un archivo ZIP deja de ser práctico. Una alternativa es alojar el código en GitHub e integrarlo con el sistema nativo de actualizaciones de WordPress.

No necesitamos reemplazar el actualizador de WordPress. El núcleo ya sabe cómo:

  • comprobar si hay versiones nuevas;
  • mostrar el aviso correspondiente;
  • descargar un ZIP;
  • instalarlo;
  • mantener activo el plugin;
  • ejecutar actualizaciones automáticas.

Nuestra integración solamente debe proporcionar la información que WordPress normalmente obtendría de WordPress.org.

Este artículo está basado en implementaciones utilizadas en plugin reales y en el enfoque presentado por Ryan Sechrest en How to enable WordPress to update your custom plugin hosted on GitHub.

Requisitos

El sistema descrito utiliza el filtro dinámico update_plugins_{$hostname}, disponible desde WordPress 5.8.

También asumiremos que:

  • el plugin se encuentra en la raíz del repositorio;
  • el archivo principal también está en la raíz;
  • main contiene la última versión estable;
  • el ZIP generado desde la rama es instalable directamente;
  • utilizamos PHP 8.0 o superior en los ejemplos.

La documentación oficial del filtro está disponible en update_plugins_{$hostname}.

Cómo descubre WordPress una actualización externa

WordPress revisa periódicamente los plugins instalados y guarda los resultados en el transient update_plugins.

En el caso de un plugin alojado fuera de WordPress.org, el encabezado Update URI determina qué filtro ejecutará WordPress.

Por ejemplo:

/**
 * Plugin Name: Mi Plugin
 * Plugin URI: https://ejemplo.com/mi-plugin/
 * Description: Un plugin personalizado.
 * Version: 1.0.0
 * Requires at least: 6.5
 * Requires PHP: 8.0
 * Author: Mi empresa
 * Update URI: https://github.com/organizacion/mi-plugin
 */

Como el hostname de Update URI es github.com, WordPress ejecutará:

update_plugins_github.com

El encabezado no implementa la actualización por sí solo. Únicamente identifica el origen externo y habilita el filtro correspondiente.

Qué debe hacer el actualizador

Nuestra clase se encargará de cinco tareas:

  1. Leer la configuración desde los encabezados del plugin.
  2. Consultar en GitHub la versión remota.
  3. Proporcionar a WordPress la URL del ZIP.
  4. Autenticar las solicitudes si el repositorio es privado.
  5. Conservar el nombre original de la carpeta después de instalar el ZIP.

Opcionalmente también puede proporcionar la información que aparece en el modal “Ver detalles”.

Crear la clase

Comenzamos almacenando la información del plugin y del repositorio:

final class Mi_Plugin_GitHub_Updater
{
    private string $file;
    private string $plugin_file;
    private string $plugin_dir;
    private string $plugin_slug;
    private string $plugin_version;
    private string $plugin_url;
    private string $update_uri;
    private string $github_owner;
    private string $github_repo;
    private string $github_branch;
    private string $github_token;

    public function __construct(
        string $file,
        string $branch = 'main',
        string $token = ''
    ) {
        $resolved = realpath($file);

        $this->file = $resolved !== false
            ? $resolved
            : wp_normalize_path($file);

        $this->github_branch = $branch;
        $this->github_token = $token;
        $this->plugin_file = plugin_basename($this->file);
        $this->plugin_dir = dirname($this->plugin_file);

        $data = get_file_data($this->file, [
            'PluginURI' => 'Plugin URI',
            'Version'   => 'Version',
            'UpdateURI' => 'Update URI',
        ]);

        $this->plugin_version =
            (string) ($data['Version'] ?? '');

        $this->plugin_url =
            (string) ($data['PluginURI'] ?? '');

        $this->update_uri =
            (string) ($data['UpdateURI'] ?? '');

        $path = trim(
            (string) wp_parse_url(
                $this->update_uri,
                PHP_URL_PATH
            ),
            '/'
        );

        [$owner, $repo] = array_pad(
            explode('/', $path, 2),
            2,
            ''
        );

        $this->github_owner = $owner;
        $this->github_repo = $repo;
        $this->plugin_slug = $owner . '-' . $repo;
    }
}

El uso de realpath() evita un problema sutil. Si pasamos una ruta como:

includes/../mi-plugin.php

plugin_basename() podría conservar el segmento ../. El resultado no coincidiría con el identificador que WordPress entrega al filtro de actualizaciones.

Registrar los filtros

Dentro de la clase añadimos:

public function add_hooks(): void
{
    add_filter(
        'update_plugins_github.com',
        [$this, 'check_update'],
        10,
        4
    );

    add_filter(
        'plugins_api',
        [$this, 'plugin_information'],
        10,
        3
    );

    add_filter(
        'http_request_args',
        [$this, 'add_github_auth_header'],
        10,
        2
    );

    add_filter(
        'upgrader_install_package_result',
        [$this, 'normalize_installed_directory'],
        10,
        2
    );
}

El filtro de actualizaciones recibe oficialmente cuatro argumentos:

$update;
$plugin_data;
$plugin_file;
$locales;

Aunque no necesitemos los idiomas instalados, registrar los cuatro argumentos hace que la firma coincida con la API de WordPress.

Obtener el archivo principal desde GitHub

Para descubrir la última versión leeremos el encabezado del archivo principal remoto.

Un repositorio público puede consultarse mediante raw.githubusercontent.com. Para un repositorio privado debemos utilizar la API de GitHub:

private function get_remote_plugin_file(): string
{
    if ($this->github_token === '') {
        $url = sprintf(
            'https://raw.githubusercontent.com/%s/%s/%s/%s',
            rawurlencode($this->github_owner),
            rawurlencode($this->github_repo),
            rawurlencode($this->github_branch),
            rawurlencode(basename($this->file))
        );

        $args = ['timeout' => 10];
    } else {
        $url = sprintf(
            'https://api.github.com/repos/%s/%s/contents/%s?ref=%s',
            rawurlencode($this->github_owner),
            rawurlencode($this->github_repo),
            rawurlencode(basename($this->file)),
            rawurlencode($this->github_branch)
        );

        $args = [
            'timeout' => 10,
            'headers' => [
                'Authorization' =>
                    'Bearer ' . $this->github_token,
                'Accept' =>
                    'application/vnd.github.raw+json',
            ],
        ];
    }

    $response = wp_remote_get($url, $args);

    if (is_wp_error($response)) {
        return '';
    }

    if (
        wp_remote_retrieve_response_code($response)
        !== 200
    ) {
        return '';
    }

    return wp_remote_retrieve_body($response);
}

GitHub recomienda application/vnd.github.raw+json para obtener directamente el contenido del archivo. La API de contenidos admite fine-grained personal access tokens con permiso Contents: Read. Puede consultarse en la documentación oficial de la API de contenidos.

Es importante comprobar tanto is_wp_error() como el código HTTP. Una respuesta de GitHub podría contener un error de autenticación, un límite de solicitudes o simplemente indicar que el archivo no existe.

Extraer la versión remota

Una vez obtenido el archivo, buscamos su encabezado Version:

private function get_remote_version(): string
{
    $body = $this->get_remote_plugin_file();

    if ($body === '') {
        return '';
    }

    if (!preg_match(
        '/^\s*\*\s*Version:\s*([0-9]+(?:\.[0-9]+){0,3})\s*$/mi',
        $body,
        $matches
    )) {
        return '';
    }

    return (string) $matches[1];
}

Este enfoque convierte la rama configurada en la fuente de verdad. Por tanto, main debe contener siempre la última versión estable y el encabezado debe actualizarse antes de publicar.

Construir la URL del ZIP

Los repositorios públicos y privados utilizan direcciones diferentes:

private function get_zip_url(): string
{
    if ($this->github_token !== '') {
        return sprintf(
            'https://api.github.com/repos/%s/%s/zipball/%s',
            rawurlencode($this->github_owner),
            rawurlencode($this->github_repo),
            rawurlencode($this->github_branch)
        );
    }

    return sprintf(
        'https://github.com/%s/%s/archive/refs/heads/%s.zip',
        rawurlencode($this->github_owner),
        rawurlencode($this->github_repo),
        rawurlencode($this->github_branch)
    );
}

El ZIP público puede descargarse sin autenticación. El endpoint zipball de un repositorio privado necesita el token.

Informar a WordPress de la actualización

Ahora podemos implementar el callback principal:

public function check_update(
    array|false $update,
    array $plugin_data,
    string $plugin_file,
    array $locales
): array|false {
    if (
        $plugin_file !== $this->plugin_file
        || (string) ($plugin_data['UpdateURI'] ?? '')
            !== $this->update_uri
    ) {
        return $update;
    }

    $remote_version = $this->get_remote_version();

    if ($remote_version === '') {
        return $update;
    }

    if (
        !version_compare(
            $remote_version,
            $this->plugin_version,
            '>'
        )
    ) {
        return $update;
    }

    return [
        'id'           => $this->update_uri,
        'slug'         => $this->plugin_slug,
        'plugin'       => $this->plugin_file,
        'version'      => $remote_version,
        'url'          => $this->plugin_url,
        'package'      => $this->get_zip_url(),
        'requires'     => '6.5',
        'requires_php' => '8.0',
        'tested'       => '6.9',
    ];
}

Nuestra clase utiliza version_compare() para detenerse pronto cuando la versión remota no es superior.

No obstante, esta comparación no es un requisito estricto del filtro: WordPress también compara internamente el campo version con la versión instalada y coloca el resultado en response o no_update.

Tampoco es obligatorio devolver new_version. Si solamente proporcionamos version, WordPress lo copiará internamente a new_version.

El valor de tested debe representar la última versión de WordPress con la que realmente probamos el plugin. No debería generarse automáticamente utilizando la versión del sitio que está realizando la consulta.

Autenticar la descarga privada

Cuando WordPress descargue el valor de package, debemos añadir el token. Hay que hacerlo únicamente para el repositorio correspondiente:

public function add_github_auth_header(
    array $args,
    string $url
): array {
    $repository_api = sprintf(
        'https://api.github.com/repos/%s/%s',
        $this->github_owner,
        $this->github_repo
    );

    if (
        $this->github_token === ''
        || !str_starts_with($url, $repository_api)
    ) {
        return $args;
    }

    if (
        !isset($args['headers'])
        || !is_array($args['headers'])
    ) {
        $args['headers'] = [];
    }

    $args['headers']['Authorization'] =
        'Bearer ' . $this->github_token;

    if (!isset($args['headers']['Accept'])) {
        $args['headers']['Accept'] =
            'application/vnd.github+json';
    }

    return $args;
}

La condición evita enviar la credencial a otros repositorios o solicitudes.

También conservamos cualquier encabezado Accept existente, porque la consulta del archivo principal necesita el tipo application/vnd.github.raw+json.

Conservar el nombre de la carpeta

El ZIP de GitHub no utiliza necesariamente el mismo nombre que la carpeta instalada.

Por ejemplo, el plugin podría estar instalado como:

mi-plugin/mi-plugin.php

Mientras que el ZIP público podría contener:

mi-plugin-main/

El ZIP privado obtenido mediante zipball puede incluir incluso el propietario y un identificador del commit.

Si dejamos que cambie la carpeta, también cambiará el identificador del plugin y WordPress podría considerarlo desactivado.

La implementación puede normalizar el resultado de la instalación:

public function normalize_installed_directory(
    array $result,
    array $hook_extra
): array {
    if (
        ($hook_extra['plugin'] ?? '')
        !== $this->plugin_file
    ) {
        return $result;
    }

    $destination =
        (string) ($result['destination'] ?? '');

    $local_destination =
        (string) (
            $result['local_destination']
            ?? WP_PLUGIN_DIR
        );

    if (
        $destination === ''
        || $this->plugin_dir === '.'
        || !str_contains(
            basename($destination),
            $this->github_repo
        )
    ) {
        return $result;
    }

    $target = trailingslashit($local_destination)
        . $this->plugin_dir;

    if (
        $destination === $target
        || !function_exists('move_dir')
    ) {
        return $result;
    }

    global $wp_filesystem;

    if (
        $wp_filesystem
        && $wp_filesystem->exists($target)
    ) {
        $wp_filesystem->delete($target, true);
    }

    $moved = move_dir($destination, $target);

    if (is_wp_error($moved)) {
        return $result;
    }

    $result['destination'] = $target;
    $result['destination_name'] =
        $this->plugin_dir;
    $result['remote_destination'] = $target;

    return $result;
}

La comprobación de $hook_extra['plugin'] es fundamental. Este filtro se ejecuta durante la instalación de otros paquetes y no debemos modificar una actualización que no pertenezca a nuestro plugin.

Implementar el modal “Ver detalles”

Cuando el usuario selecciona “Ver detalles”, WordPress consulta su API de plugins. Como nuestro plugin no está en WordPress.org, podemos interceptar la petición mediante plugins_api.

La documentación especifica que, para la acción plugin_information, debemos devolver un objeto:

public function plugin_information(
    mixed $result,
    string $action,
    object $args
): mixed {
    if (
        $action !== 'plugin_information'
        || ($args->slug ?? '')
            !== $this->plugin_slug
    ) {
        return $result;
    }

    $remote_version = $this->get_remote_version();

    if ($remote_version === '') {
        return $result;
    }

    return (object) [
        'name'         => 'Mi Plugin',
        'slug'         => $this->plugin_slug,
        'version'      => $remote_version,
        'author'       => 'Mi empresa',
        'homepage'     => $this->plugin_url,
        'requires'     => '6.5',
        'requires_php' => '8.0',
        'tested'       => '6.9',
        'sections'     => [
            'description' =>
                'Descripción completa del plugin.',
            'changelog' =>
                'Consulta GitHub para ver los cambios.',
        ],
        'download_link' => $this->get_zip_url(),
    ];
}

El filtro plugins_api evita que WordPress consulte WordPress.org para este slug y permite conservar la experiencia nativa del administrador.

Cargar el actualizador

Guardamos la clase en un archivo como:

includes/github-updater.php

Y la cargamos desde el archivo principal:

require_once __DIR__
    . '/includes/github-updater.php';

$github_token =
    defined('MI_PLUGIN_GITHUB_TOKEN')
    && is_string(MI_PLUGIN_GITHUB_TOKEN)
        ? MI_PLUGIN_GITHUB_TOKEN
        : '';

(new Mi_Plugin_GitHub_Updater(
    __FILE__,
    'main',
    $github_token
))->add_hooks();

Para un repositorio público no hace falta configurar ninguna credencial.

Para uno privado podemos declarar el token en wp-config.php:

define(
    'MI_PLUGIN_GITHUB_TOKEN',
    'github_pat_xxxxxxxxx'
);

Es recomendable utilizar un fine-grained personal access token limitado al repositorio y con el permiso:

Contents: Read-only

Nunca debemos guardar un token real en Git.

Cómo publicar una versión

El actualizador lee la versión desde el archivo principal de la rama estable. El flujo de publicación debe garantizar que esa versión sea siempre superior a la instalada.

Por ejemplo:

/**
 * Version: 1.1.0
 */

define('MI_PLUGIN_VERSION', '1.1.0');

Un proceso de publicación típico sería:

  1. Incrementar la versión del encabezado.
  2. Actualizar cualquier constante interna equivalente.
  3. Ejecutar pruebas.
  4. Generar dependencias y assets de producción.
  5. Confirmar que el paquete descargado desde GitHub es instalable.
  6. Hacer commit.
  7. Enviar el commit a la rama estable.

WordPress detectará la actualización la próxima vez que renueve el transient update_plugins.

Forzar una comprobación durante las pruebas

WordPress conserva en caché los resultados de las actualizaciones. Por eso, durante el desarrollo, una versión recién publicada puede no aparecer inmediatamente.

Podemos forzar una consulta:

delete_site_transient('update_plugins');
wp_update_plugins();

Esta operación debe ejecutarse únicamente desde una acción administrativa protegida mediante:

  • la capacidad update_plugins;
  • un nonce;
  • una redirección segura.

No debemos borrar el transient en cada carga de WordPress porque eso generaría solicitudes innecesarias a GitHub.

El detalle más importante: ¿qué ZIP descarga realmente?

En este ejemplo, el actualizador descarga el ZIP de la rama main.

No descarga:

dist/mi-plugin-1.1.0.zip

Tampoco descarga automáticamente un archivo adjunto a una GitHub Release.

Esto significa que el repositorio debe contener todo lo que el plugin necesita en producción:

  • dependencias de Composer;
  • assets compilados;
  • archivos generados;
  • autoloaders;
  • cualquier otro recurso necesario.

Si vendor/ no está versionado y el ZIP de GitHub no lo contiene, la actualización dejará el plugin incompleto.

Cuando el paquete de producción es diferente del código fuente, resulta más apropiado publicar un ZIP como asset de una GitHub Release y modificar el actualizador para descargar ese artefacto.

Además, una rama es mutable: el archivo usado para comprobar la versión y el ZIP descargado posteriormente podrían corresponder a commits distintos. Para obtener publicaciones reproducibles, una evolución recomendable es consultar una versión etiquetada o una GitHub Release.

Repositorios privados y almacenamiento del token

Existen varias formas de proporcionar el token:

Constante en wp-config.php

Es la opción más sencilla y recomendable cuando administramos el servidor:

define(
    'MI_PLUGIN_GITHUB_TOKEN',
    'github_pat_xxxxxxxxx'
);

Opción en la base de datos

El token también puede guardarse mediante:

update_option(
    'mi_plugin_github_token',
    $token,
    false
);

El tercer argumento evita cargarlo automáticamente en cada petición, pero no lo cifra. Cualquier persona con acceso a la base de datos podría leerlo.

Token temporal dentro del instalador

En una distribución privada controlada se puede preparar un ZIP inicial que incluya temporalmente el token. Durante la activación, el plugin puede importarlo y borrar inmediatamente el archivo.

Este método facilita instalaciones administradas, pero tiene un riesgo evidente: el ZIP contiene la credencial en texto legible antes de instalarse.

Si se adopta este mecanismo:

  • el ZIP debe tratarse como un archivo privado;
  • el token debe limitarse a un único repositorio;
  • solamente debe tener acceso de lectura;
  • el archivo temporal debe borrarse al activarse;
  • el token debe poder revocarse y rotarse.

No es una práctica recomendable para plugins distribuidos públicamente.

Problemas frecuentes

La actualización no aparece

Comprueba que:

  • Update URI sea correcto;
  • la rama configurada exista;
  • el archivo principal esté en la ruta esperada;
  • la versión remota sea superior;
  • WordPress haya renovado el transient;
  • GitHub responda con código 200.

Un repositorio privado devuelve 404

GitHub puede responder con 404 cuando el token no existe o no tiene acceso al repositorio. Revisa los permisos y la selección de repositorios del fine-grained token.

El plugin se desactiva después de actualizarse

Normalmente significa que la carpeta del ZIP no se normalizó y WordPress cambió el identificador del plugin.

El plugin pierde sus dependencias

El ZIP de la rama no contiene archivos ignorados por Git. Incluye las dependencias en el repositorio o descarga un artefacto construido desde GitHub Releases.

“Ver detalles” intenta abrir WordPress.org

Comprueba que el slug devuelto en la actualización coincida con el utilizado por el callback de plugins_api.

WordPress continúa mostrando una versión anterior

El resultado probablemente sigue en el transient update_plugins. Fuerza una comprobación manual desde una acción administrativa segura.

Conclusión

Una actualización desde GitHub puede integrarse con WordPress sin reemplazar su sistema nativo.

Las piezas esenciales son:

  • un encabezado Update URI;
  • el filtro dinámico asociado a su hostname;
  • una fuente confiable para consultar la versión;
  • la URL del paquete;
  • autenticación limitada para repositorios privados;
  • normalización de la carpeta instalada;
  • un proceso de publicación que produzca un paquete completo.

Para proyectos sencillos, descargar la rama estable puede ser suficiente. Para plugins con compilación, dependencias privadas o requisitos estrictos de trazabilidad, GitHub Releases ofrece una base más sólida y reproducible.

Implementarlo con un agente

Si prefieres delegar la implementación, publiqué la skill WordPress GitHub Updater. Puedes instalarla en cualquier agente compatible con SKILL.md o compartirle directamente su URL junto con este prompt:

Usa la skill wordpress-github-updater para inspeccionar mi plugin e implementar sus actualizaciones nativas desde GitHub. Determina primero si debe descargar el ZIP de una rama o un asset de GitHub Releases, contempla si el repositorio es público o privado y valida el paquete y la actualización completa antes de dar el trabajo por terminado. No publiques versiones ni expongas credenciales sin mi autorización.

Comentarios

Deja una respuesta

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