Un artefacto es el resultado de un build que vas a guardar o desplegar: un paquete comprimido, un binario, una imagen de contenedor o un informe de pruebas. Gestionarlos bien significa que cada artefacto tiene una versión que lo identifica, se construye una sola vez, se puede verificar que no ha cambiado y se borra cuando ya no hace falta. En este tutorial montarás ese ciclo con GitHub Actions para una aplicación web: versionado a partir de etiquetas Git, paquete con checksum SHA256, paso de artefactos entre jobs, publicación en GitHub Releases, copia en un almacenamiento compatible con S3 y descarga verificada en un servidor Ubuntu 24.04.

Requisitos previos

  • Un repositorio en GitHub con un proyecto Node.js cuyo npm run build genere los archivos finales en dist/ (el mismo esquema sirve para cualquier otro lenguaje cambiando el comando de build).
  • La CLI de GitHub (gh) autenticada en tu máquina local.
  • Un bucket en un servicio de almacenamiento de objetos compatible con S3, con un par de claves de acceso con permiso de escritura y otro de solo lectura.
  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con un usuario no root con sudo, donde se descargarán y desplegarán los artefactos.

Dónde guardar cada tipo de artefacto

No todos los artefactos tienen el mismo ciclo de vida. Elegir el almacén adecuado evita tanto perder builds que necesitas como acumular gigas de builds que nadie usará:

AlmacénPara quéRetención típica
Artefactos de GitHub Actions (upload-artifact)Pasar archivos entre jobs de una misma ejecución, informes de pruebasDías
Caché de Actions (actions/cache, setup-node)Dependencias descargadas (~/.npm), no resultados del buildSe renueva sola
GitHub ReleasesVersiones publicadas para usuarios o para desplegarIndefinida
Almacenamiento S3Copia independiente de GitHub, builds de main, descargas desde servidoresSegún la política del bucket
Registro de contenedoresImágenes DockerSegún política de limpieza del registro

Una confusión frecuente es usar la caché para pasar el resultado del build a otro job. La caché puede desaparecer en cualquier momento y se comparte entre ejecuciones; para resultados de build usa siempre artefactos.

Paso 1: Definir el esquema de versiones

Cada artefacto necesita un nombre que diga exactamente de qué código sale. La forma más simple y fiable es derivarlo de Git con git describe: en un commit etiquetado devuelve la etiqueta, y en cualquier otro commit añade cuántos commits hay desde la última etiqueta y el hash abreviado.

Etiqueta la primera versión siguiendo versionado semántico (MAJOR.MINOR.PATCH) y compruébalo:

git tag -a v1.2.0 -m "Versión 1.2.0"
git describe --tags --always
v1.2.0

Tras dos commits más sin etiquetar:

v1.2.0-2-g8c1f3ab

Ese valor es único por commit y ordenable a simple vista. --always hace que, si el repositorio aún no tiene etiquetas, devuelva al menos el hash en lugar de fallar.

Paso 2: Empaquetar el build con versión y checksum

Crea un script que empaquete dist/ con la versión en el nombre, incluya un archivo version.json dentro del propio paquete y genere su checksum SHA256. Así el mismo script funciona en tu máquina y en CI.

mkdir -p scripts
nano scripts/empaquetar.sh
#!/usr/bin/env bash
set -euo pipefail

version="$(git describe --tags --always)"
commit="$(git rev-parse HEAD)"
nombre="miapp-${version}"

if [[ ! -d dist ]]; then
    echo "No existe dist/, ejecuta antes npm run build" >&2
    exit 1
fi

printf '{"version":"%s","commit":"%s","fecha":"%s"}\n' \
    "$version" "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > dist/version.json

mkdir -p out
tar -czf "out/${nombre}.tar.gz" -C dist .
(cd out && sha256sum "${nombre}.tar.gz" > "${nombre}.tar.gz.sha256")

echo "$version"
chmod +x scripts/empaquetar.sh

Pruébalo en local:

npm ci
npm run build
./scripts/empaquetar.sh
ls out/
v1.2.0
miapp-v1.2.0.tar.gz  miapp-v1.2.0.tar.gz.sha256

El archivo .sha256 permite a cualquiera comprobar que el paquete descargado es idéntico al que construyó el pipeline. version.json dentro del paquete te dirá, una vez desplegado, qué versión y qué commit están sirviéndose. Añade out/ a tu .gitignore.

Paso 3: Construir una vez y pasar el artefacto entre jobs

El workflow construye y empaqueta en un job y sube el resultado con actions/upload-artifact. Los jobs posteriores lo descargan con actions/download-artifact en lugar de volver a compilar: así lo que se prueba, se publica y se despliega es exactamente el mismo archivo.

Crea .github/workflows/artefactos.yml:

name: artefactos

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-24.04
    outputs:
      version: ${{ steps.paquete.outputs.version }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run build

      - name: Empaquetar
        id: paquete
        run: echo "version=$(./scripts/empaquetar.sh)" >> "$GITHUB_OUTPUT"

      - uses: actions/upload-artifact@v4
        with:
          name: paquete
          path: out/
          if-no-files-found: error
          retention-days: 7

Algunos detalles importan:

  • fetch-depth: 0 descarga el historial completo con las etiquetas. Con el clon superficial por defecto, git describe no encuentra ninguna etiqueta y devuelve solo el hash.
  • cache: npm en setup-node guarda la caché de npm entre ejecuciones. Es caché de dependencias, no el artefacto.
  • if-no-files-found: error hace fallar el job si el build no produjo nada, en lugar de subir un artefacto vacío.
  • retention-days: 7 limita cuánto se conserva este artefacto. Las versiones que importan se guardarán en Releases y en S3 en los pasos siguientes.

Haz un push y abre la ejecución en la pestaña Actions: al final del resumen, en Artifacts, aparece paquete con su tamaño y un enlace de descarga.

Paso 4: Publicar las versiones en GitHub Releases

Cuando empujas una etiqueta v*, el pipeline debe crear una release con el paquete y su checksum. Este job descarga el artefacto del job build, así que no recompila nada. Añádelo al final del workflow:

  release:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-24.04
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: paquete
          path: out/

      - name: Comprobar checksum
        working-directory: out
        run: sha256sum -c ./*.sha256

      - name: Crear release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: >-
          gh release create "$GITHUB_REF_NAME" out/*
          --repo "$GITHUB_REPOSITORY"
          --title "$GITHUB_REF_NAME"
          --generate-notes
          --verify-tag

Solo este job tiene contents: write, el permiso necesario para crear releases. --verify-tag evita que gh cree una etiqueta nueva si por error no existiera, y --generate-notes redacta las notas a partir de los pull requests fusionados desde la versión anterior.

Publica una versión y comprueba el resultado:

git tag -a v1.2.1 -m "Versión 1.2.1"
git push origin v1.2.1
gh release view v1.2.1
v1.2.1
Published by github-actions[bot] less than a minute ago

  ## What's Changed
  ...

ASSETS
miapp-v1.2.1.tar.gz         1.4 MiB
miapp-v1.2.1.tar.gz.sha256  86 B

Paso 5: Copiar los artefactos en un almacenamiento S3

Guardar además una copia en un bucket S3 te da un almacén independiente de GitHub, con acceso mediante claves de solo lectura desde tus servidores, y te permite conservar también los builds de main que no son versiones publicadas. Para subirlos se usa rclone, que funciona con cualquier proveedor compatible con S3 y se puede configurar solo con variables de entorno.

Guarda las credenciales de escritura como secretos del repositorio:

gh secret set S3_ACCESS_KEY_ID
gh secret set S3_SECRET_ACCESS_KEY
gh variable set S3_ENDPOINT --body "https://s3.tu_proveedor.com"
gh variable set S3_BUCKET --body "artefactos-miapp"

gh secret set te pedirá el valor de forma interactiva, sin dejarlo en el historial de la shell. Añade este job al workflow:

  s3:
    needs: build
    if: github.event_name == 'push'
    runs-on: ubuntu-24.04
    env:
      RCLONE_CONFIG_ALMACEN_TYPE: s3
      RCLONE_CONFIG_ALMACEN_PROVIDER: Other
      RCLONE_CONFIG_ALMACEN_ENDPOINT: ${{ vars.S3_ENDPOINT }}
      RCLONE_CONFIG_ALMACEN_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
      RCLONE_CONFIG_ALMACEN_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: paquete
          path: out/

      - name: Instalar rclone
        run: sudo apt-get update && sudo apt-get install -y rclone

      - name: Subir al bucket
        env:
          BUCKET: ${{ vars.S3_BUCKET }}
          VERSION: ${{ needs.build.outputs.version }}
        run: |
          if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
            destino="releases/$VERSION"
          else
            destino="snapshots/$VERSION"
          fi
          rclone copy out/ "almacen:$BUCKET/$destino/" --immutable -v

Las variables RCLONE_CONFIG_ALMACEN_* definen un remoto llamado almacen sin archivo de configuración. Las versiones etiquetadas van a releases/ y los builds de main a snapshots/, lo que permite aplicarles retenciones distintas en el paso 7. La opción --immutable hace fallar la subida si ya existe un archivo con ese nombre y contenido distinto: una versión publicada no debe sobrescribirse nunca.

Tras el siguiente push, el log del paso muestra los archivos copiados:

INFO  : miapp-v1.2.1-1-g3d9e0aa.tar.gz.sha256: Copied (new)
INFO  : miapp-v1.2.1-1-g3d9e0aa.tar.gz: Copied (new)

Paso 6: Descargar y verificar el artefacto en el servidor

En el servidor Ubuntu 24.04, instala rclone desde los repositorios de Ubuntu:

sudo apt update
sudo apt install -y rclone

Crea su configuración con las claves de solo lectura, para que un servidor comprometido no pueda alterar los artefactos:

mkdir -p ~/.config/rclone
nano ~/.config/rclone/rclone.conf
[almacen]
type = s3
provider = Other
endpoint = https://s3.tu_proveedor.com
access_key_id = tu_clave_de_lectura
secret_access_key = tu_secreto_de_lectura
chmod 600 ~/.config/rclone/rclone.conf

Lista las versiones disponibles:

rclone lsf almacen:artefactos-miapp/releases/
v1.2.0/
v1.2.1/

Descarga una versión y verifica el checksum antes de descomprimir nada:

rclone copy almacen:artefactos-miapp/releases/v1.2.1/ ~/descargas/v1.2.1/
cd ~/descargas/v1.2.1
sha256sum -c miapp-v1.2.1.tar.gz.sha256
miapp-v1.2.1.tar.gz: OK

Si la salida es FAILED, el archivo está corrupto o incompleto: no lo despliegues. Con la verificación superada, descomprime en un directorio por versión y activa la versión con un enlace simbólico. Así conservas las versiones anteriores y volver a una de ellas es cambiar el enlace:

sudo mkdir -p /var/www/miapp/releases/v1.2.1
sudo tar -xzf miapp-v1.2.1.tar.gz -C /var/www/miapp/releases/v1.2.1
sudo ln -sfn /var/www/miapp/releases/v1.2.1 /var/www/miapp/current
cat /var/www/miapp/current/version.json
{"version":"v1.2.1","commit":"3d9e0aa51c7f...","fecha":"2026-09-25T09:41:07Z"}

Apunta la raíz de tu servidor web (por ejemplo, root /var/www/miapp/current; en Nginx) al enlace current.

Si prefieres descargar desde GitHub Releases en lugar de S3, gh hace lo mismo:

gh release download v1.2.1 --repo tu_usuario/miapp --pattern 'miapp-*'

Paso 7: Aplicar una política de retención

Sin limpieza, los artefactos crecen hasta que el almacenamiento cuesta más que el proyecto. Define cuánto vive cada tipo:

  • Artefactos de Actions: el retention-days: 7 del paso 3 ya los elimina. El valor por defecto del repositorio se ajusta en Settings > Actions > General > Artifact and log retention.
  • Releases: se conservan. Son pocas y son tu historial de versiones.
  • Snapshots en S3: basta con conservar unos días. Si tu proveedor admite reglas de ciclo de vida (lifecycle rules) en el bucket, crea una que expire los objetos con prefijo snapshots/ a los 30 días. Si no, prográmalo con rclone en un servidor con credenciales de escritura.

Prueba primero qué se borraría con --dry-run:

rclone delete --min-age 30d --dry-run almacen:artefactos-miapp/snapshots/

Cuando el resultado sea el esperado, prográmalo con cron para cada noche:

crontab -e
15 4 * * * rclone delete --min-age 30d --rmdirs almacen:artefactos-miapp/snapshots/ >> "$HOME/rclone-limpieza.log" 2>&1

En el servidor web, borra también las versiones antiguas de /var/www/miapp/releases/, conservando siempre la activa y al menos una anterior para poder volver atrás.

Solución de problemas

git describe devuelve solo un hash aunque existen etiquetas: el checkout es superficial. Añade fetch-depth: 0 a actions/checkout, y comprueba que la etiqueta está subida con git ls-remote --tags origin.

Failed to CreateArtifact: ... an artifact with this name already exists: en upload-artifact@v4 los nombres son únicos por ejecución y los artefactos no se pueden modificar. Usa nombres distintos por job o por entrada de la matriz (por ejemplo, paquete-${{ matrix.os }}).

Unable to download artifact(s): Artifact not found for name: paquete: el job que descarga no depende del que sube. Añade el job de build a needs.

HTTP 403 al ejecutar gh release create: falta permissions: contents: write en ese job.

rclone devuelve SignatureDoesNotMatch o AccessDenied: el endpoint o las claves no son correctos, o el proveedor exige una región concreta. Algunos proveedores necesitan además la opción region en la configuración del remoto; consulta su documentación.

Conclusión

Tus builds ahora tienen una versión derivada de Git, se construyen una sola vez, viajan entre jobs como artefactos, se publican con checksum SHA256 en GitHub Releases y en un almacenamiento S3, y se verifican en el servidor antes de desplegarse, con una retención distinta para versiones y snapshots. Como siguientes pasos, puedes firmar los paquetes además de publicar su checksum (por ejemplo con cosign sign-blob), aplicar el mismo esquema de etiquetas inmutables a tus imágenes Docker y automatizar la descarga y el cambio de enlace current desde el propio pipeline por SSH.