Fins ara el pipeline produeix un directori dist/ que es desa set dies i no va enlloc. En aquesta lliçó tanques el circuit: empaquetaràs Mini-Reservalia en una imatge de contenidor immutable, la publicaràs en un registre real, la desplegaràs en dos entorns amb una porta d'aprovació humana entremig, comprovaràs automàticament que el desplegament funciona, promocionaràs a producció exactament el mateix artefacte que vas validar a staging —sense reconstruir-lo— i escriuràs el botó d'emergència que et torna a la versió anterior en menys de dos minuts. I, com mana el mòdul, trencaràs alguna cosa expressament: desplegaràs una versió que falla el seu propi health check per veure la porta tancar-se i el rollback funcionar.

Tot això sense AWS i sense targeta de crèdit. El registre és ghcr.io, inclòs de franc al teu compte de GitHub; el frontend va a GitHub Pages; i l'«amfitrió de producció» és un contenidor Docker que corre al mateix runner o a la teva màquina. Allà on el Reservalia real faria servir ECS, ECR i OIDC contra AWS, hi trobaràs una nota amb l'equivalent exacte. Els conceptes —artefacte immutable, digest, idempotència, promoció, smoke test, rollback— són idèntics; només canvia on aterra el contenidor.

Contingut

  1. Objectiu, requisits previs i punt de partida
  2. L'artefacte immutable: Dockerfile multietapa
  3. Construir i publicar a ghcr.io des del pipeline
  4. Per què el digest i no l'etiqueta
  5. Separar CI de CD amb workflow_run
  6. Environments: staging automàtic i produccio amb revisor
  7. El script de desplegament idempotent
  8. Les tres maneres d'executar-lo (amb amfitrió, sense amfitrió i al runner)
  9. El smoke test amb reintents
  10. El cd.yml complet amb promoció per digest
  11. El frontend a GitHub Pages
  12. El rollback.yml i el cronòmetre
  13. Provocar un desplegament dolent
  14. Verificació final
  15. Errors Comuns i Consells
  16. Exercicis
  17. Conclusió

  1. Objectiu, requisits previs i punt de partida

Objectiu. En acabar, un merge a main construirà una imatge, la publicarà a ghcr.io, la desplegarà automàticament a staging, la verificarà amb un smoke test, esperarà la teva aprovació i promocionarà el mateix digest a produccio; i tindràs un rollback.yml que reverteix en menys de dos minuts, cronometrat.

Requisits previs.

  • Les lliçons 07-01 i 07-02 completades.
  • Docker instal·lat a la teva màquina (docker --version, 24 o superior) i docker compose version. Ara sí que és obligatori.
  • El repositori a GitHub amb main protegida i la comprovació CI OK.

Punt de partida. El repositori després de la 07-02: codi amb persistència, tres capes de proves, cobertura amb llindar, matriu i sharding.

git checkout main && git pull
git checkout -b desplegament

  1. L'artefacte immutable: Dockerfile multietapa

La regla de la 02-06: build once, deploy many. Es construeix un artefacte una sola vegada i aquest mateix artefacte —byte a byte— recorre tots els entorns. Si cada entorn reconstrueix, cada entorn executa una cosa diferent i la validació de staging no diu res sobre producció.

Dockerfile:

# syntax=docker/dockerfile:1.7

# ---------- Etapa 1: dependencies de produccio ----------
# S aïlla perque la capa es guardi a la memoria cau mentre el lockfile no canviï.
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# build-essential i python3 nomes calen SI algun modul natiu
# (better-sqlite3) no troba binari precompilat. Es queden en aquesta
# etapa i no arriben mai a la imatge final.
RUN apt-get update && apt-get install -y --no-install-recommends \
      python3 make g++ \
    && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# --omit=dev: res d eslint ni d eines de prova en produccio.
RUN npm ci --omit=dev

# ---------- Etapa 2: construccio ----------
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG COMMIT=desconegut
ENV GITHUB_SHA=${COMMIT}
RUN npm run build

# ---------- Etapa 3: imatge final ----------
FROM node:20-bookworm-slim AS runtime
WORKDIR /app

# Usuari sense privilegis. La imatge oficial de Node ja porta l usuari
# `node` (uid 1000); no cal crear-lo.
ENV NODE_ENV=production \
    PORT=3000 \
    BASE_DADES=sqlite:/dades/mini.db

# Directori de dades amb permisos per a l usuari no-root.
RUN mkdir -p /dades && chown -R node:node /dades

COPY --from=deps  --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./

USER node
EXPOSE 3000
VOLUME ["/dades"]

# Metadades OCI: qui ha construit aixo, des de quin commit i quan.
# Es llegeixen amb `docker inspect` i son la traçabilitat minima d un artefacte.
ARG COMMIT=desconegut
ARG DATA=desconeguda
LABEL org.opencontainers.image.source="https://github.com/OWNER/mini-reservalia" \
      org.opencontainers.image.revision="${COMMIT}" \
      org.opencontainers.image.created="${DATA}" \
      org.opencontainers.image.title="mini-reservalia" \
      org.opencontainers.image.description="Calcul de forats de cita"

# HEALTHCHECK: Docker sondeja el contenidor i en marca l estat.
# `docker inspect --format '{{.State.Health.Status}}'` retorna
# starting -> healthy | unhealthy. L script de desplegament ho fara servir.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

ENV APP_VERSION=${COMMIT}
CMD ["node", "dist/src/servidor.js"]

.dockerignore —tan important com el Dockerfile, perquè determina què s'envia al dimoni i què invalida la memòria cau—:

node_modules
dist
coverage
informes
.git
.github
.gitignore
*.md
*.log
.env
Dockerfile
docker-compose*.yml
test

Excloure test/ i .git no és només velocitat: és superfície. Una imatge de producció que porta a dins l'historial de Git i les proves és una imatge que filtra informació i ocupa el triple.

Construeix-la i prova-la en local:

docker build \
  --build-arg COMMIT="$(git rev-parse HEAD)" \
  --build-arg DATA="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t mini-reservalia:local .

docker run --rm -d --name mini-local -p 3000:3000 mini-reservalia:local
sleep 3
curl -s localhost:3000/salut
# {"estat":"ok","versio":"8f3c1e2...","actiuSeg":3}

# L estat del HEALTHCHECK, que es el que mirara el desplegament:
docker inspect --format '{{.State.Health.Status}}' mini-local
# healthy    (pot trigar fins a 15 s a passar de "starting")

# Comprovacions d enduriment basic:
docker exec mini-local whoami       # node   (NO root)
docker image inspect mini-reservalia:local --format '{{.Size}}' | numfmt --to=iec
# ~230M

docker rm -f mini-local

Què has de veure: estat: ok, Health.Status: healthy i whoami: node. Si whoami retorna root, la línia USER node no hi és o és abans d'un COPY que l'anul·la.

  1. Construir i publicar a ghcr.io des del pipeline

ghcr.io és el registre de contenidors de GitHub. Per a un repositori públic és gratuït i il·limitat, i el millor: no et cal cap credencial nova. El GITHUB_TOKEN que el runner ja té serveix, sempre que li donis el permís packages: write.

Afegeix al ci.yml un job publicar que depengui de ci-ok:

  publicar:
    name: Publicar imatge
    runs-on: ubuntu-latest
    needs: [ci-ok]
    # Nomes es publica des de main: un PR no ha de deixar imatges al registre.
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    permissions:
      contents: read
      packages: write      # necessari per escriure a ghcr.io
    outputs:
      digest: ${{ steps.construir.outputs.digest }}
      imatge: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Preparar Buildx
        uses: docker/setup-buildx-action@v3

      - name: Autenticar-se a ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # token efimer de l execucio

      - name: Metadades i etiquetes
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long,prefix=sha-
            type=raw,value=main,enable={{is_default_branch}}

      - name: Construir i publicar
        id: construir
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            COMMIT=${{ github.sha }}
            DATA=${{ github.event.repository.updated_at }}
          # Memoria cau de capes al mateix backend d Actions: redueix molt
          # el temps de les construccions seguents (04-04 i 06-05).
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true    # attestacio de procedencia (SLSA)

      - name: Publicar el digest al resum
        run: |
          {
            echo "## Imatge publicada"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Repositori | \`ghcr.io/${{ github.repository }}\` |"
            echo "| Digest | \`${{ steps.construir.outputs.digest }}\` |"
            echo "| Commit | \`${{ github.sha }}\` |"
            echo ""
            echo "Referencia immutable per desplegar:"
            echo '```'
            echo "ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

Fes el merge del PR i observa. Què has de veure:

  1. Al resum de l'execució, la taula amb el digest: sha256:3f9a....
  2. A la portada del teu perfil o del repositori, la secció Packages amb mini-reservalia.
  3. La imatge és privada per defecte encara que el repositori sigui públic. Ves a Package settings → Change visibility → Public si vols poder-la descarregar sense autenticar-te (útil per al laboratori). Alternativament, a Package settings → Manage Actions access, afegeix-hi el repositori amb rol Write.

Descarrega-la des de la teva màquina per comprovar que existeix de debò:

echo "$GH_TOKEN" | docker login ghcr.io -u EL_TEU_USUARI --password-stdin
docker pull ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:3f9a...
docker run --rm -p 3000:3000 ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:3f9a...

Equivalent real a Reservalia. El registre és ECR i l'autenticació no fa servir cap contrasenya sinó OIDC: el flux de treball demana un token efímer a AWS amb aws-actions/configure-aws-credentials@v4 i role-to-assume: arn:aws:iam::...:role/reservalia-ci, sense cap clau emmagatzemada a GitHub (03-02 i 04-03). Aquí ghcr.io amb GITHUB_TOKEN compleix exactament el mateix principi —credencial efímera, amb abast mínim, que caduca en acabar l'execució—, i per això l'exercici no hi perd res pedagògicament. La 07-05 hi tornarà.

  1. Per què el digest i no l'etiqueta

Aquest apartat és curt i és el més important de la lliçó.

Una etiqueta (tag) és un punter mutable. ghcr.io/teu/mini-reservalia:main avui apunta a una imatge i demà a una altra. Un digest (sha256:...) és el hash del contingut del manifest: una referència per digest sempre resol als mateixos bytes, per sempre.

Etiqueta Digest
:main, :latest, :v1.2 Punter mutable
@sha256:3f9a... Contingut immutable
Pot canviar sota els teus peus? No
Serveix per promocionar? No
Serveix per tornar enrere? Només si ningú no l'ha mogut Sempre
Serveix per parlar amb humans? Regular

L'escenari que arruïna el dia d'algú cada mes: desplegues :main a staging, ho proves, ho aproves, i quan el job de producció fa docker pull, un altre merge ja ha mogut :main. Producció executa codi que ningú no ha validat. No és una fallada teòrica; és la raó per la qual aquest pipeline passa el digest d'un job a un altre i per la qual el job de producció comprova que el digest que desplegarà és el mateix que es va validar.

Comprova-ho tu mateix:

docker buildx imagetools inspect ghcr.io/EL_TEU_USUARI/mini-reservalia:main --format '{{.Manifest.Digest}}'
# sha256:3f9a...   <- ara
# fes un altre merge a main i repeteix: el digest ha canviat, l etiqueta no

Regla: les etiquetes són per a les persones, els digests per a les màquines. Publica-les totes dues; desplega sempre per digest.

  1. Separar CI de CD amb workflow_run

CI i CD són dos cicles amb velocitats i permisos diferents. La CI s'executa a cada PR, no necessita credencials de desplegament i ha de ser ràpida. El CD s'executa només després d'un merge a main, necessita permisos elevats i pot trigar minuts esperant una aprovació. Ficar-los al mateix flux de treball obliga a donar a cada PR els permisos del desplegament: exactament el contrari del privilegi mínim.

flowchart LR
    P["push a main"] --> CI["ci.yml<br/>qualitat, test, cobertura,<br/>build, publicar imatge"]
    CI -->|workflow_run: completed + success| CD["cd.yml"]
    CD --> S["desplegar staging<br/>automatic"]
    S --> SM1["smoke test"]
    SM1 --> G{"Environment<br/>produccio<br/>revisor requerit"}
    G -->|aprovat| PR2["desplegar produccio<br/>MATEIX digest"]
    PR2 --> SM2["smoke test"]
    SM2 -->|falla| RB["rollback.yml"]

El disparador:

on:
  workflow_run:
    workflows: ['CI']        # el `name:` de l altre flux de treball, no el seu fitxer
    types: [completed]
    branches: [main]
  workflow_dispatch:          # per poder rellançar un desplegament a ma
    inputs:
      digest:
        description: 'Digest a desplegar (sha256:...). Buit = ultim de main'
        required: false

I la guarda imprescindible al primer job:

    # `completed` inclou failure i cancelled. Sense aquesta condicio,
    # desplegaries el resultat d una CI en vermell.
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event.workflow_run.conclusion == 'success'

Dues peculiaritats de workflow_run que confonen tothom la primera vegada:

  1. El flux de treball ha d'existir a main perquè es dispari. Mentre el cd.yml només sigui a la teva branca, no s'executarà mai per molt que la CI passi. Cal fer-ne el merge primer i provar-lo després. És contraintuïtiu i costa mitja tarda a qui no ho sap.
  2. El context és el del flux de treball disparador, no el del commit. github.sha en un workflow_run és el SHA de la branca per defecte en el moment del dispar. Si necessites el commit exacte que s'ha construït, llegeix-lo de github.event.workflow_run.head_sha.

  1. Environments: staging automàtic i produccio amb revisor

Els Environments de GitHub són la materialització de la porta entre Delivery i Deployment de la 03-01: l'artefacte està a punt, però algú decideix quan entra.

Crea'ls a Settings → Environments:

staging

  • Sense regles de protecció.
  • Environment URL: l'adreça del teu staging (o http://localhost:3001).
  • Variable d'entorn (pestanya Variables): PORT_APP = 3001.

produccio

  • Required reviewers: afegeix-t'hi tu mateix. Aquest és el punt de l'exercici.
  • Wait timer: 0 (o 1 minut si vols veure el temporitzador).
  • Deployment branches: Selected branchesmain. Impedeix desplegar producció des d'una branca qualsevol.
  • Variable: PORT_APP = 3002.

Amb gh:

gh api --method PUT "repos/{owner}/{repo}/environments/staging"

gh api --method PUT "repos/{owner}/{repo}/environments/produccio" \
  -F "wait_timer=0" \
  -F "reviewers[][type]=User" \
  -F "reviewers[][id]=$(gh api user --jq .id)" \
  -F "deployment_branch_policy[protected_branches]=true" \
  -F "deployment_branch_policy[custom_branch_policies]=false"

Un job s'associa a un entorn amb dues línies:

    environment:
      name: produccio
      url: ${{ steps.desplegar.outputs.url }}

El que fa GitHub amb això: quan l'execució arriba a aquest job, s'atura, marca l'execució com a Waiting, envia una notificació als revisors i espera. Cap pas del job no s'executa —ni tan sols el checkout— fins que algú aprova. Els secrets i les variables de l'entorn només es materialitzen després de l'aprovació, cosa que significa que un job no aprovat no pot tocar les credencials de producció ni per accident ni expressament. Aquest és el valor de seguretat, a més del de procés.

  1. El script de desplegament idempotent

Aquí apliquem la regla de la 06-07: la lògica als scripts, el YAML prim. El script ha de poder executar-se des del teu portàtil, des del runner o des d'un servidor per SSH, sense canvis. Si només funciona dins de GitHub Actions, no el pots depurar i no el pots fer servir en una emergència.

scripts/desplegar.sh:

#!/usr/bin/env bash
# Desplega Mini-Reservalia com a contenidor Docker.
#
# IDEMPOTENT: executar-lo N vegades amb la mateixa imatge deixa el sistema en el
# mateix estat que executar-lo una sola vegada. Aquesta propietat es el que permet
# reintentar un desplegament fallit sense por (03-02).
#
# Us:
#   ENTORN=staging PORT_AMFITRIO=3001 IMATGE=ghcr.io/x/y@sha256:... ./scripts/desplegar.sh
#
# Variables:
#   IMATGE          (obligatoria) referencia COMPLETA per digest
#   ENTORN          (per defecte: staging) sufix del nom del contenidor
#   PORT_AMFITRIO   (per defecte: 3001) port de l amfitrio
#   RETENIR         (per defecte: 3) quantes imatges antigues s han de conservar

set -Eeuo pipefail   # -E: les trampes s hereten; -e: avorta en fallar;
                     # -u: variable no definida es error; -o pipefail: falla la canonada sencera

IMATGE="${IMATGE:?Falta IMATGE (referencia per digest)}"
ENTORN="${ENTORN:-staging}"
PORT_AMFITRIO="${PORT_AMFITRIO:-3001}"
RETENIR="${RETENIR:-3}"

CONTENIDOR="mini-reservalia-${ENTORN}"
VOLUM="mini-reservalia-dades-${ENTORN}"

log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }

# --- 0. Validacions primerenques ----------------------------------------------
if [[ "$IMATGE" != *"@sha256:"* ]]; then
  echo "ERROR: IMATGE ha de ser una referencia per DIGEST (@sha256:...), no per etiqueta." >&2
  echo "       Rebut: $IMATGE" >&2
  echo "       Una etiqueta es mutable: no garanteix que desplegues el que has validat." >&2
  exit 2
fi
command -v docker >/dev/null || { echo "ERROR: docker no esta instal.lat" >&2; exit 3; }

log "Entorn:      $ENTORN"
log "Contenidor:  $CONTENIDOR"
log "Port:        $PORT_AMFITRIO"
log "Imatge:      $IMATGE"

# --- 1. Descarregar la imatge ABANS de tocar res -------------------------------
# Si el pull falla, el servei actual continua viu. No paris mai el que funciona
# abans de tenir el que l ha de substituir.
log "Descarregant la imatge..."
docker pull --quiet "$IMATGE"

DIGEST_LOCAL="$(docker image inspect "$IMATGE" --format '{{index .RepoDigests 0}}')"
log "Digest verificat: $DIGEST_LOCAL"

# --- 2. Idempotencia: si ja corre AQUESTA imatge, no facis res -----------------
if docker ps --filter "name=^${CONTENIDOR}$" --format '{{.Names}}' | grep -q .; then
  ACTUAL="$(docker inspect "$CONTENIDOR" --format '{{.Image}}')"
  NOVA="$(docker image inspect "$IMATGE" --format '{{.Id}}')"
  if [[ "$ACTUAL" == "$NOVA" ]]; then
    log "El contenidor ja executa aquesta imatge. Res a fer (idempotencia)."
    docker ps --filter "name=^${CONTENIDOR}$" --format 'table {{.Names}}\t{{.Status}}'
    exit 0
  fi
  log "La versio en execucio es diferent; se substitueix."
fi

# --- 3. Desar la versio anterior per al rollback -------------------------------
ANTERIOR=""
if docker inspect "$CONTENIDOR" >/dev/null 2>&1; then
  ANTERIOR="$(docker inspect "$CONTENIDOR" --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null || true)"
fi
if [[ -n "$ANTERIOR" ]]; then
  log "Versio anterior (per al rollback): $ANTERIOR"
  echo "$ANTERIOR" > "/tmp/mini-reservalia-${ENTORN}.anterior"
  if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
    echo "digest_anterior=$ANTERIOR" >> "$GITHUB_OUTPUT"
  fi
fi

# --- 4. Volum de dades (idempotent per definicio) ------------------------------
docker volume create "$VOLUM" >/dev/null

# --- 5. Parar i esborrar l anterior --------------------------------------------
# `|| true` perque al primer desplegament no existeix, i aixo no es un error.
log "Aturant la versio anterior (si existeix)..."
docker rm -f "$CONTENIDOR" >/dev/null 2>&1 || true

# --- 6. Arrencar la versio nova ------------------------------------------------
log "Arrencant la versio nova..."
docker run -d \
  --name "$CONTENIDOR" \
  --restart unless-stopped \
  -p "${PORT_AMFITRIO}:3000" \
  -v "${VOLUM}:/dades" \
  -e "APP_VERSION=${APP_VERSION:-$IMATGE}" \
  -e "ENTORN=${ENTORN}" \
  --label "mini.digest=${IMATGE}" \
  --label "mini.entorn=${ENTORN}" \
  --label "mini.desplegat=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --health-cmd "node -e \"fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
  --health-interval 5s --health-retries 6 --health-start-period 3s \
  "$IMATGE" >/dev/null

# --- 7. Esperar que el HEALTHCHECK passi a healthy -----------------------------
log "Esperant que el contenidor sigui healthy..."
for i in $(seq 1 24); do
  ESTAT="$(docker inspect "$CONTENIDOR" --format '{{.State.Health.Status}}' 2>/dev/null || echo 'sense-dades')"
  case "$ESTAT" in
    healthy)   log "Healthy despres de ${i} sondejos."; break ;;
    unhealthy) log "ERROR: el contenidor esta unhealthy."; docker logs --tail 50 "$CONTENIDOR"; exit 4 ;;
    *)         sleep 5 ;;
  esac
  if [[ "$i" -eq 24 ]]; then
    log "ERROR: no ha arribat a healthy en 120 s (estat: $ESTAT)."
    docker logs --tail 50 "$CONTENIDOR"
    exit 5
  fi
done

# --- 8. Neteja: conservar les N ultimes imatges --------------------------------
# Sense aixo, el disc de l amfitrio s omple en unes setmanes. Es la causa numero
# u de "el desplegament ha fallat i no sabem per que" en amfitrions petits.
log "Netejant imatges antigues (conservant-ne $RETENIR)..."
docker image prune -f --filter "until=168h" >/dev/null 2>&1 || true

log "Desplegament completat: $CONTENIDOR al port $PORT_AMFITRIO"
docker ps --filter "name=^${CONTENIDOR}$" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
chmod +x scripts/desplegar.sh

Les quatre propietats que fan que aquest script sigui utilitzable en producció:

Propietat Com s'aconsegueix Què passa sense ella
Idempotent Comprova si ja corre aquesta imatge i surt (pas 2) Reintentar un desplegament reinicia el servei sense necessitat
Falla aviat pull abans de parar res (pas 1) Pares el que funciona i descobreixes que la imatge nova no existeix
Verificable Espera a healthy amb límit de temps (pas 7) El script acaba «bé» amb un contenidor que ni tan sols arrenca
Rastrejable Desa el digest anterior en una etiqueta (passos 3 i 6) No saps a què tornar en un rollback

Aquest últim punt mereix èmfasi: l'etiqueta mini.digest converteix el mateix contenidor en el registre del que hi ha desplegat. docker inspect mini-reservalia-produccio --format '{{index .Config.Labels "mini.digest"}}' respon la pregunta més urgent d'un incident: què dimonis estem executant?

  1. Les tres maneres d'executar-lo (amb amfitrió, sense amfitrió i al runner)

El mateix script, tres destinacions. Tria la que puguis.

Opció A (principal, sense cost): la destinació simulada dins del runner

El job de desplegament executa el script contra el Docker del mateix runner. El «servidor» viu els tres minuts del job i es destrueix després. És artificial —no hi ha persistència entre desplegaments— però exercita el 100 % del camí: pull per digest, arrencada, health check, smoke test, rollback. És l'opció per defecte d'aquest laboratori.

      - name: Desplegar
        run: ./scripts/desplegar.sh
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
          ENTORN: staging
          PORT_AMFITRIO: '3001'

Opció B: la teva màquina, amb un runner autoallotjat

Si vols persistència real entre desplegaments i veure el contenidor viu al teu portàtil, registra un runner autoallotjat:

mkdir ~/runner && cd ~/runner
# Copia les ordres exactes de Settings > Actions > Runners > New self-hosted runner
./config.sh --url https://github.com/EL_TEU_USUARI/mini-reservalia --token XXXX --labels casa
./run.sh

I canvia runs-on: ubuntu-latest per runs-on: [self-hosted, casa]. Ara http://localhost:3001 i http://localhost:3002 són els teus dos entorns de debò, sobreviuen als desplegaments i els pots veure amb docker ps.

Avís de seguretat, no opcional. No posis un runner autoallotjat en un repositori públic: qualsevol que obri un PR podria executar codi arbitrari a la teva màquina. La 06-06 ho explicava i la 07-05 hi tornarà. Si vas per l'opció B, fes el repositori privat o fes servir un contenidor exhaurible com a runner.

Opció C: un amfitrió real per SSH

Si tens una màquina accessible (una VM antiga, una Raspberry Pi, un VPS d'un euro), aquest és el camí més semblant a producció:

      - name: Desplegar per SSH
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
        run: |
          install -m 600 /dev/null clau
          echo "${{ secrets.SSH_CLAU_PRIVADA }}" > clau
          # StrictHostKeyChecking=accept-new: confia la primera vegada i despres
          # detecta canvis d amfitrio. NO facis servir MAI `no`: desactiva la
          # proteccio contra suplantacio per sempre.
          scp -i clau -o StrictHostKeyChecking=accept-new \
            scripts/desplegar.sh "${{ secrets.SSH_USUARI }}@${{ secrets.SSH_HOST }}:/tmp/"
          ssh -i clau -o StrictHostKeyChecking=accept-new \
            "${{ secrets.SSH_USUARI }}@${{ secrets.SSH_HOST }}" \
            "IMATGE='$IMATGE' ENTORN=produccio PORT_AMFITRIO=3002 bash /tmp/desplegar.sh"
          shred -u clau

Amb docker-compose.yml a l'amfitrió per tenir també el reverse proxy:

# docker-compose.yml - l "amfitrio de produccio" simulat a la teva maquina
services:
  api-staging:
    image: ${IMATGE_STAGING:-ghcr.io/OWNER/mini-reservalia:main}
    container_name: mini-reservalia-staging
    restart: unless-stopped
    ports: ['3001:3000']
    environment:
      ENTORN: staging
      BASE_DADES: sqlite:/dades/mini.db
    volumes: ['dades-staging:/dades']
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3

  api-produccio:
    image: ${IMATGE_PRODUCCIO:-ghcr.io/OWNER/mini-reservalia:main}
    container_name: mini-reservalia-produccio
    restart: unless-stopped
    ports: ['3002:3000']
    environment:
      ENTORN: produccio
      BASE_DADES: sqlite:/dades/mini.db
    volumes: ['dades-produccio:/dades']
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3

volumes:
  dades-staging:
  dades-produccio:

Equivalent real a Reservalia. El desplegament és aws ecs update-service amb una definició de tasca nova que apunta al digest, i ECS fa un rolling update respectant el health check del target group de l'ALB (03-02 i 03-04). El script desplegar.sh de Reservalia fa el mateix que el teu —comprovar la idempotència, aplicar, esperar l'estabilització, informar— amb aws ecs wait services-stable en lloc del bucle de docker inspect. La forma del script és idèntica; canvia la primitiva.

  1. El smoke test amb reintents

Un desplegament que acaba no és un desplegament que funciona. El smoke test és la diferència.

scripts/smoke.sh:

#!/usr/bin/env bash
# Smoke test posterior al desplegament.
# No prova funcionalitat exhaustiva (per a aixo hi ha la suite): comprova que
# el sistema DESPLEGAT respon i fa la seva funcio principal.
#
# Us: BASE=http://localhost:3001 ./scripts/smoke.sh [digest_esperat]

set -Eeuo pipefail

BASE="${BASE:?Falta BASE (p. ex. http://localhost:3001)}"
ESPERAT="${1:-}"
INTENTS="${INTENTS:-20}"
ESPERA="${ESPERA:-3}"

log() { printf '[smoke] %s\n' "$*"; }
fallada() { echo "[smoke] FALLADA: $*" >&2; exit 1; }

# --- 1. Espera d estabilitzacio ------------------------------------------------
# El servei pot trigar a acceptar connexions. Reintentar NO es tapar un
# problema: es reconeixer que l arrencada no es instantania. El que si que seria
# tapar el problema es reintentar SENSE limit o ignorar el resultat final.
log "Esperant que $BASE respongui (max $((INTENTS * ESPERA))s)..."
for i in $(seq 1 "$INTENTS"); do
  if curl -fsS --max-time 5 "$BASE/salut" >/dev/null 2>&1; then
    log "Respon despres de $((i * ESPERA))s aprox."
    break
  fi
  [[ "$i" -eq "$INTENTS" ]] && fallada "no ha respost a /salut en $((INTENTS * ESPERA))s"
  sleep "$ESPERA"
done

# --- 2. /salut retorna estat ok ------------------------------------------------
SALUT="$(curl -fsS --max-time 5 "$BASE/salut")"
log "/salut -> $SALUT"
echo "$SALUT" | grep -q '"estat":"ok"' || fallada "/salut no retorna estat ok"

# --- 3. La versio desplegada es l esperada -------------------------------------
# Aquesta comprovacio es la que caça la fallada mes silenciosa de totes:
# el desplegament "ha funcionat" pero el trafic continua anant a la versio antiga.
if [[ -n "$ESPERAT" ]]; then
  VERSIO="$(echo "$SALUT" | sed -n 's/.*"versio":"\([^"]*\)".*/\1/p')"
  if [[ "$VERSIO" != *"$ESPERAT"* ]]; then
    fallada "versio desplegada '$VERSIO' != esperada '$ESPERAT'"
  fi
  log "Versio verificada: $VERSIO"
fi

# --- 4. La funcionalitat principal respon --------------------------------------
RESPOSTA="$(curl -fsS --max-time 10 "$BASE/api/forats?data=2026-03-02&duracio=60")"
echo "$RESPOSTA" | grep -q '"forats"' || fallada "/api/forats no retorna forats: $RESPOSTA"
TOTAL="$(echo "$RESPOSTA" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')"
[[ "${TOTAL:-0}" -gt 0 ]] || fallada "/api/forats retorna 0 forats en un dia que n hauria de tenir"
log "/api/forats -> $TOTAL forats"

# --- 5. Els errors continuen sent errors ---------------------------------------
# Una API que respon 200 a tot tambe "passa" un smoke test ingenu.
CODI="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/forats")"
[[ "$CODI" == "400" ]] || fallada "una peticio sense data hauria de donar 400, ha donat $CODI"
log "Validacio d errors OK (400 sense data)"

# --- 6. Latencia raonable ------------------------------------------------------
MS="$(curl -s -o /dev/null -w '%{time_total}' --max-time 10 "$BASE/api/forats?data=2026-03-02" \
      | awk '{printf "%d", $1 * 1000}')"
log "Latencia de /api/forats: ${MS} ms"
[[ "$MS" -lt 2000 ]] || fallada "latencia de ${MS} ms, per sobre del llindar de 2000 ms"

log "TOTES LES COMPROVACIONS OK"
chmod +x scripts/smoke.sh

El pas 5 és el que separa un smoke test útil d'un de decoratiu: comprova que una cosa que ha de fallar, falla. Un servidor mal configurat que retorna 200 amb una pàgina d'error per a qualsevol ruta passaria els passos 1 a 4 sense despentinar-se.

  1. El cd.yml complet amb promoció per digest

# .github/workflows/cd.yml
name: CD

on:
  workflow_run:
    workflows: ['CI']
    types: [completed]
    branches: [main]
  workflow_dispatch:
    inputs:
      digest:
        description: 'Digest a desplegar (sha256:...). Buit = ultim publicat a main'
        required: false
        type: string

# No cancel.lis MAI un desplegament a mitges: pot deixar el sistema inconsistent.
# S encuen, no es cancel.len. Diferencia clau respecte al `concurrency` de la CI.
concurrency:
  group: cd-mini-reservalia
  cancel-in-progress: false

permissions:
  contents: read

env:
  REGISTRE: ghcr.io
  IMATGE_BASE: ghcr.io/${{ github.repository }}

jobs:
  # ---------------------------------------------------------------
  # 1. Resoldre QUE es desplegara. Una sola vegada, per a tothom.
  # ---------------------------------------------------------------
  preparar:
    name: Resoldre artefacte
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event.workflow_run.conclusion == 'success'
    permissions:
      contents: read
      packages: read
    outputs:
      digest: ${{ steps.resoldre.outputs.digest }}
      imatge: ${{ steps.resoldre.outputs.imatge }}
      commit: ${{ steps.resoldre.outputs.commit }}
    steps:
      - name: Autenticar-se al registre
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Resoldre el digest a desplegar
        id: resoldre
        run: |
          set -Eeuo pipefail
          ENTRADA="${{ inputs.digest }}"
          if [[ -n "$ENTRADA" ]]; then
            DIGEST="$ENTRADA"
            echo "Digest indicat a ma: $DIGEST"
          else
            # Resolem l etiqueta mobil :main al seu digest UNA sola vegada.
            # A partir d aqui, tot el pipeline fa servir el digest fix.
            DIGEST=$(docker buildx imagetools inspect "${{ env.IMATGE_BASE }}:main" \
                       --format '{{.Manifest.Digest}}')
            echo "Digest resolt des de :main -> $DIGEST"
          fi

          IMATGE="${{ env.IMATGE_BASE }}@${DIGEST}"
          COMMIT=$(docker buildx imagetools inspect "$IMATGE" --format \
                    '{{json .Image}}' | grep -o '"org.opencontainers.image.revision":"[^"]*"' \
                    | cut -d'"' -f4 || echo "${{ github.sha }}")

          {
            echo "digest=$DIGEST"
            echo "imatge=$IMATGE"
            echo "commit=$COMMIT"
          } >> "$GITHUB_OUTPUT"

          {
            echo "## Artefacte a desplegar"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Imatge | \`$IMATGE\` |"
            echo "| Commit | \`$COMMIT\` |"
            echo "| Origen | ${{ github.event_name }} |"
          } >> "$GITHUB_STEP_SUMMARY"

  # ---------------------------------------------------------------
  # 2. Staging: automatic, sense aprovacio.
  # ---------------------------------------------------------------
  staging:
    name: Desplegar a staging
    runs-on: ubuntu-latest
    needs: [preparar]
    timeout-minutes: 15
    environment:
      name: staging
      url: http://localhost:3001
    permissions:
      contents: read
      packages: read
    outputs:
      digest_validat: ${{ steps.marcar.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Desplegar
        id: desplegar
        run: ./scripts/desplegar.sh
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
          ENTORN: staging
          PORT_AMFITRIO: ${{ vars.PORT_APP || '3001' }}
          APP_VERSION: ${{ needs.preparar.outputs.commit }}

      - name: Smoke test
        run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
        env:
          BASE: http://localhost:${{ vars.PORT_APP || '3001' }}

      - name: Marcar el digest com a validat
        id: marcar
        run: |
          echo "digest=${{ needs.preparar.outputs.digest }}" >> "$GITHUB_OUTPUT"
          echo "### ✅ Staging validat: \`${{ needs.preparar.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"

      - name: Diagnostic si alguna cosa falla
        if: failure()
        run: |
          echo "::group::Contenidors"
          docker ps -a
          echo "::endgroup::"
          echo "::group::Logs"
          docker logs --tail 100 mini-reservalia-staging || true
          echo "::endgroup::"

  # ---------------------------------------------------------------
  # 3. Produccio: MATEIX digest, amb aprovacio humana.
  # ---------------------------------------------------------------
  produccio:
    name: Desplegar a produccio
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    timeout-minutes: 30
    environment:
      name: produccio          # <- aqui s atura esperant l aprovacio
      url: http://localhost:3002
    permissions:
      contents: read
      packages: read
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # LA COMPROVACIO CLAU DE LA PROMOCIO.
      # Produccio desplega EXACTAMENT el que staging ha validat. Si per
      # qualsevol motiu el digest no coincideix, avortem: mes val no desplegar
      # que desplegar una cosa diferent de la validada.
      - name: Verificar que es el MATEIX artefacte validat a staging
        run: |
          set -Eeuo pipefail
          VALIDAT="${{ needs.staging.outputs.digest_validat }}"
          A_DESPLEGAR="${{ needs.preparar.outputs.digest }}"
          echo "Validat a staging:   $VALIDAT"
          echo "A desplegar a prod:  $A_DESPLEGAR"
          if [[ "$VALIDAT" != "$A_DESPLEGAR" ]]; then
            echo "::error::El digest de produccio NO coincideix amb el validat a staging."
            exit 1
          fi
          echo "✅ Mateix artefacte. No es reconstrueix res." >> "$GITHUB_STEP_SUMMARY"

      - name: Desplegar
        id: desplegar
        run: ./scripts/desplegar.sh
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
          ENTORN: produccio
          PORT_AMFITRIO: ${{ vars.PORT_APP || '3002' }}
          APP_VERSION: ${{ needs.preparar.outputs.commit }}

      - name: Smoke test de produccio
        run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
        env:
          BASE: http://localhost:${{ vars.PORT_APP || '3002' }}

      - name: Registrar el desplegament
        run: |
          {
            echo "## 🚀 Desplegat a produccio"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
            echo "| Commit | \`${{ needs.preparar.outputs.commit }}\` |"
            echo "| Aprovat per | @${{ github.actor }} |"
            echo "| Hora (UTC) | $(date -u +%Y-%m-%dT%H:%M:%SZ) |"
            echo ""
            echo "**Digest anterior (per al rollback):** \`${{ steps.desplegar.outputs.digest_anterior || 'cap' }}\`"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Diagnostic si alguna cosa falla
        if: failure()
        run: |
          docker ps -a
          docker logs --tail 100 mini-reservalia-produccio || true
          echo "::error::Desplegament de produccio fallit. Llanca el flux de treball Rollback amb el digest anterior."

Fes el PR, fes-ne el merge i observa la seqüència completa:

  1. El ci.yml s'executa i publica la imatge.
  2. Uns segons després arrenca el cd.yml tot sol (fixa't que l'execució diu «triggered by CI»).
  3. Resoldre artefacte imprimeix el digest.
  4. Desplegar a staging s'executa i el smoke test passa.
  5. L'execució s'atura. El job Desplegar a produccio apareix amb un avís groc: «Deployment protection rules — Review required» i un botó Review deployments.
  6. Prem el botó, marca produccio, escriu un comentari i prem Approve and deploy.
  7. El job arrenca, verifica el digest, desplega i publica el resum.

Aquell moment en què el pipeline t'espera és la porta de la 03-01 feta realitat. Val la pena mirar-lo un segon abans d'aprovar.

  1. El frontend a GitHub Pages

Mini-Reservalia també té web. Crea web/index.html:

<!doctype html>
<html lang="ca">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mini-Reservalia</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
    .forat { display: inline-block; padding: .4rem .8rem; margin: .2rem; border: 1px solid #888; border-radius: .4rem; }
    #meta { color: #666; font-size: .85rem; margin-top: 2rem; }
  </style>
</head>
<body>
  <h1>Mini-Reservalia</h1>
  <label>Data <input type="date" id="data" value="2026-03-02"></label>
  <label>Durada <input type="number" id="duracio" value="60" min="15" step="15"></label>
  <button id="cercar">Cercar forats</button>
  <div id="resultat"></div>
  <div id="meta"></div>
  <script src="app.js"></script>
</body>
</html>

web/app.js — amb la configuració en temps d'execució, no incrustada al build (05-01):

// web/app.js
// La configuracio es llegeix de config.json EN TEMPS D EXECUCIO.
// Aixi el MATEIX artefacte estatic serveix per a staging i per a produccio:
// l unica cosa que canvia es el config.json que escriu el desplegament.
// Si la URL de l API estigues incrustada al build, cada entorn
// necessitaria el seu propi build i es trenca "build once, deploy many".
let CONFIG = { apiBase: 'http://localhost:3001', entorn: 'desconegut', versio: 'dev' };

async function carregarConfig() {
  try {
    CONFIG = { ...CONFIG, ...(await (await fetch('config.json', { cache: 'no-store' })).json()) };
  } catch {
    console.warn('config.json no disponible; es fa servir la configuracio per defecte');
  }
  document.getElementById('meta').textContent =
    `Entorn: ${CONFIG.entorn} · Versio: ${CONFIG.versio} · API: ${CONFIG.apiBase}`;
}

async function cercar() {
  const data = document.getElementById('data').value;
  const duracio = document.getElementById('duracio').value;
  const sortida = document.getElementById('resultat');
  sortida.textContent = 'Cercant...';
  try {
    const resposta = await fetch(`${CONFIG.apiBase}/api/forats?data=${data}&duracio=${duracio}`);
    const dades = await resposta.json();
    if (!resposta.ok) throw new Error(dades.error ?? 'error desconegut');
    sortida.innerHTML = dades.total === 0
      ? '<p>No queden forats aquell dia.</p>'
      : `<p>${dades.total} forats:</p>` +
        dades.forats.map((f) => `<span class="forat">${f.inici}–${f.fi}</span>`).join('');
  } catch (error) {
    sortida.textContent = `Error: ${error.message}`;
  }
}

document.getElementById('cercar').addEventListener('click', cercar);
carregarConfig();

Job de desplegament de Pages, al cd.yml:

  web:
    name: Desplegar web (Pages)
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    permissions:
      contents: read
      pages: write            # publicar a Pages
      id-token: write         # OIDC cap al servei de Pages
    environment:
      name: github-pages
      url: ${{ steps.publicar.outputs.page_url }}
    steps:
      - uses: actions/checkout@v4

      - name: Generar el config.json de l entorn
        run: |
          cat > web/config.json <<JSON
          {
            "apiBase": "${{ vars.API_BASE || 'http://localhost:3002' }}",
            "entorn": "produccio",
            "versio": "${{ needs.preparar.outputs.commit }}",
            "digest": "${{ needs.preparar.outputs.digest }}"
          }
          JSON
          cat web/config.json

      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: web/
      - id: publicar
        uses: actions/deploy-pages@v4

Activa Pages a Settings → Pages → Source: GitHub Actions. Què has de veure: després del desplegament, https://EL_TEU_USUARI.github.io/mini-reservalia/ amb el formulari i, al peu, la línia Entorn: produccio · Versio: 8f3c1e2 · API: ....

Un detall honest del laboratori: la web pública no podrà cridar la teva API a localhost (i si ho fes, el navegador bloquejaria la petició per CORS). I està bé: el que il·lustra aquest job és el desplegament atòmic d'un artefacte estàtic amb configuració en temps d'execució, que és la part traslladable. A Reservalia, aquest mateix patró puja el dist/ de Vite a S3 i fa una invalidació de CloudFront, amb el mateix config.json generat al desplegament (05-01).

  1. El rollback.yml i el cronòmetre

Tornar enrere no ha de requerir pensar. Un rollback que exigeix recordar ordres és un rollback que no es farà servir a les tres de la matinada.

# .github/workflows/rollback.yml
name: Rollback

on:
  workflow_dispatch:
    inputs:
      entorn:
        description: 'Entorn a revertir'
        required: true
        default: 'produccio'
        type: choice
        options: [staging, produccio]
      digest:
        description: 'Digest al qual tornar (sha256:...)'
        required: true
        type: string
      motiu:
        description: 'Motiu (queda registrat al resum)'
        required: true
        type: string

concurrency:
  group: cd-mini-reservalia    # MATEIX grup que el cd.yml: un rollback i un
  cancel-in-progress: false    # desplegament no s han de solapar mai

permissions:
  contents: read

jobs:
  rollback:
    name: Revertir ${{ inputs.entorn }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      packages: read
    # Compte: perque un rollback sigui RAPID, l entorn de rollback NO ha de
    # tenir revisor requerit, o tornaras a esperar una aprovacio en plena
    # incidencia. Fem servir un entorn diferent, sense porta.
    environment:
      name: ${{ inputs.entorn }}-rollback
    steps:
      - name: Cronometre - inici
        id: inici
        run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"

      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Validar el digest rebut
        run: |
          set -Eeuo pipefail
          DIGEST="${{ inputs.digest }}"
          [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || {
            echo "::error::'$DIGEST' no es un digest valid (sha256: + 64 hex)"; exit 2; }
          # Comprovar que existeix ABANS de tocar produccio.
          docker buildx imagetools inspect "ghcr.io/${{ github.repository }}@${DIGEST}" >/dev/null
          echo "Digest verificat i disponible al registre."

      - name: Executar el rollback
        run: ./scripts/desplegar.sh
        env:
          IMATGE: ghcr.io/${{ github.repository }}@${{ inputs.digest }}
          ENTORN: ${{ inputs.entorn }}
          PORT_AMFITRIO: ${{ inputs.entorn == 'produccio' && '3002' || '3001' }}

      - name: Verificar amb el smoke test
        run: ./scripts/smoke.sh
        env:
          BASE: http://localhost:${{ inputs.entorn == 'produccio' && '3002' || '3001' }}

      - name: Cronometre - fi i registre
        if: always()
        run: |
          SEGONS=$(( $(date +%s) - ${{ steps.inici.outputs.t }} ))
          {
            echo "## ⏪ Rollback de ${{ inputs.entorn }}"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Digest restaurat | \`${{ inputs.digest }}\` |"
            echo "| Motiu | ${{ inputs.motiu }} |"
            echo "| Executat per | @${{ github.actor }} |"
            echo "| **Durada** | **${SEGONS} s** |"
            echo "| Resultat | ${{ job.status }} |"
            echo ""
            echo "> Temps de restauracio del servei (metrica DORA #4)."
          } >> "$GITHUB_STEP_SUMMARY"
          echo "Rollback completat en ${SEGONS} s"

Executa'l:

# Esbrina el digest anterior (el penultim publicat)
gh api "/user/packages/container/mini-reservalia/versions" \
  --jq '.[1] | "\(.name)  \(.created_at)"'

gh workflow run rollback.yml \
  -f entorn=produccio \
  -f digest=sha256:ANTERIOR... \
  -f motiu="Prova cronometrada del procediment de rollback"

gh run watch

Què has de veure: el resum amb la durada. En aquest laboratori el rollback triga 60-110 segons, la major part al docker pull. Reservalia triga 4 minuts perquè ECS fa un rolling update amb drenatge de connexions. Tots dos estan molt per sota de l'objectiu de 10 minuts, i tots dos estan mesurats, que és el que importa: un procediment de rollback que ningú no ha cronometrat és una suposició.

  1. Provocar un desplegament dolent

Ara la part divertida: comprovar que la porta tanca.

git checkout -b trencar-salut

Edita src/servidor.js i sabota /salut:

       if (req.method === 'GET' && url.pathname === '/salut') {
+        // FALLADA DELIBERADA: simula una dependencia critica caiguda
+        if (process.env.ENTORN) {
+          return respondreJson(res, 503, { estat: 'degradat', error: 'base de dades no disponible' });
+        }
         return respondreJson(res, 200, {

La condició process.env.ENTORN fa que les proves locals i la CI continuïn en verd (no defineixen ENTORN) però que el contenidor desplegat falli. És una simulació bastant fidel de la categoria de bug més perillosa: el que només apareix amb la configuració d'un entorn real.

npm test        # verd: les proves NO ho detecten
git commit -am "fix: comprovacio addicional a /salut"
git push -u origin trencar-salut
gh pr create --fill && gh pr merge --squash --delete-branch --auto

Què has de veure, en ordre:

  1. CI en verd. Les 38 proves passen. La imatge es publica. Aquest és el punt: la CI no ho detecta.
  2. El cd.yml arrenca. Resoldre artefacte OK.
  3. Desplegar a staging falla. I falla en dos llocs, cosa que és interessant:
    • Primer, dins de desplegar.sh, el pas 7: el HEALTHCHECK del contenidor no arriba mai a healthy.
      [10:42:31] Esperant que el contenidor sigui healthy...
      [10:44:31] ERROR: no ha arribat a healthy en 120 s (estat: unhealthy)
      
    • Si el health check fos més laxe, ho caçaria l'smoke.sh:
      [smoke] FALLADA: /salut no retorna estat ok
      
  4. El pas de diagnòstic bolca els logs del contenidor, gràcies a l'if: failure().
  5. Desplegar a produccio no s'executa. Ni tan sols demana aprovació: el seu needs: [staging] no s'ha complert. Producció no veu mai aquest codi.

Aquesta és exactament la promesa del desplegament continu ben muntat: un bug que la CI no detecta s'atura al primer entorn real, automàticament, sense que ningú hagi de mirar res.

Ara simula que hagués arribat a producció. Força el desplegament de la versió dolenta a producció i executa el rollback cronometrant-lo:

# 1. Desplegar a ma la versio dolenta a produccio
gh workflow run cd.yml -f digest=sha256:DOLENT...
# aprova-ho al web quan ho demani; el smoke test de produccio fallara

# 2. Rollback, amb el cronometre en marxa
date +%s
gh workflow run rollback.yml \
  -f entorn=produccio \
  -f digest=sha256:BO... \
  -f motiu="Incident: /salut retorna 503 despres del desplegament de sha256:DOLENT"
gh run watch
date +%s

Apunta els tres números: temps de detecció (quant ha trigat el smoke test a fallar), temps de decisió (quant has trigat a trobar el digest bo) i temps d'execució (el que diu el resum del rollback). En un incident real, el segon acostuma a ser el més gran dels tres, i és el que es redueix anotant el digest anterior al resum de cada desplegament, que és exactament el que fa el nostre cd.yml.

Reverteix el sabotatge:

git checkout main && git pull
git checkout -b arreglar-salut
git revert --no-edit <sha-del-commit-dolent>
git push -u origin arreglar-salut
gh pr create --fill && gh pr merge --squash --delete-branch --auto

  1. Verificació final

# Comprovació Com Esperat
1 La imatge no corre com a root docker run --rm IMATGE whoami node
2 El HEALTHCHECK funciona docker inspect --format '{{.State.Health.Status}}' healthy
3 La imatge és a ghcr.io Pestanya Packages mini-reservalia amb versions
4 El desplegament és idempotent Executar desplegar.sh dues vegades La 2a diu «Res a fer»
5 El script rebutja una etiqueta IMATGE=ghcr.io/x/y:main ./scripts/desplegar.sh Sortida 2, missatge sobre el digest
6 El CD es dispara sol després de la CI Merge a main Execució de CD «triggered by CI»
7 Producció espera aprovació Mirar l'execució Review required + botó
8 Es promociona el mateix digest Log del pas de verificació Els dos digests idèntics
9 No es reconstrueix per a producció Log del job No hi ha cap docker build
10 El smoke test detecta la fallada Sabotejar /salut Staging en vermell, producció sense executar
11 El rollback està cronometrat Resum de l'execució Durada en segons, < 10 min
12 El digest anterior queda registrat Resum de cada desplegament Línia «Digest anterior»

Errors Comuns i Consells

Símptoma: denied: installation not allowed to Create organization package en fer push a ghcr.io. Causa: falta permissions: packages: write al job, o el repositori té restringit el permís per defecte del GITHUB_TOKEN a només lectura. Arranjament: afegeix el bloc permissions al job (no n'hi ha prou de tenir-lo a nivell de flux de treball si el job el sobreescriu) i revisa Settings → Actions → General → Workflow permissions.

Símptoma: Error response from daemon: unauthorized en fer docker pull de la teva pròpia imatge des d'un altre lloc. Causa: el paquet és privat per defecte, encara que el repositori sigui públic. Arranjament: Packages → mini-reservalia → Package settings → Change visibility → Public, o Manage Actions access per donar accés al repositori.

Símptoma: el cd.yml no es dispara mai, encara que la CI acabi en verd. Causes possibles, per ordre de freqüència: (1) el fitxer encara no és a mainworkflow_run només es dispara amb la versió de la branca per defecte—; (2) el workflows: ['CI'] no coincideix amb el name: de l'altre flux de treball (distingeix majúscules); (3) la CI ha acabat amb conclusion: failure i la guarda de l'if ho ha bloquejat correctament. Arranjament: fes primer el merge, comprova el name: exacte i mira el conclusion a l'API: gh run list --workflow=ci.yml --json conclusion,name.

Símptoma: desplegar.sh falla amb unbound variable a la línia de GITHUB_OUTPUT. Causa: set -u amb una variable no definida en executar el script fora d'Actions. Arranjament: ja està previst amb ${GITHUB_OUTPUT:-}. Si escrius els teus propis scripts, l'expansió amb valor per defecte és obligatòria en tot el que vingui de l'entorn.

Símptoma: el contenidor arrenca i mor immediatament, sense logs útils. Causa habitual: permisos del volum. L'usuari node (uid 1000) no pot escriure a /dades si el volum es va crear amb propietari root. Arranjament: el RUN mkdir -p /dades && chown -R node:node /dades del Dockerfile ho resol per a volums nous. Si el volum ja existia amb altres permisos: docker volume rm mini-reservalia-dades-staging i torna a desplegar. Diagnòstic: docker logs mini-reservalia-staging (per això el pas if: failure() els bolca).

Símptoma: el smoke test passa però està provant la versió antiga. Causa: el contenidor nou no ha arrencat i el reverse proxy continua encaminant al vell, o el port respon una altra cosa. Arranjament: per això smoke.sh compara la versió retornada per /salut amb l'esperada. Sense aquesta comprovació, un desplegament que no ha fet res passa el smoke test perfectament. És la fallada més silenciosa de totes.

Símptoma: el disc de l'amfitrió s'omple al cap d'unes setmanes. Causa: cada desplegament deixa una imatge sense fer servir de ~230 MB. Arranjament: el docker image prune del pas 8. En un amfitrió real, a més, una tasca setmanal de docker system prune -af --filter "until=720h" i una alerta d'espai de disc. La 07-04 posarà aquesta alerta.

Consell — mai latest. docker pull imatge:latest és la manera més eficient de no saber què estàs executant. El nostre script ho rebutja explícitament. Que les teves eines impedeixin l'error és molt millor que documentar que no s'ha de cometre.

Consell — el concurrency del CD és diferent del de la CI. A la CI, cancel-in-progress: true (ningú no vol el resultat d'un commit superat). Al CD, false (cancel·lar un desplegament a mitges deixa el sistema en un estat que ningú no ha dissenyat). I el rollback.yml comparteix grup amb el cd.yml perquè no es trepitgin.

Exercicis

Exercici 1: rollback sense buscar el digest a mà

En un incident, trobar el digest anterior és el pas lent. Fes que el rollback.yml accepti l'input digest buit i, en aquest cas, resolgui automàticament la versió immediatament anterior a la desplegada.

Exercici 2: desplegament canary per percentatge

Implementa un desplegament canary: aixeca la versió nova al costat de l'antiga i envia només el 10 % del trànsit a la nova durant 2 minuts; si el smoke test estès passa, promociona-la al 100 %; si no, retira la canary. Fes servir un contenidor Nginx com a balancejador.

Exercici 3: bloquejar el desplegament fora de l'horari laboral

Afegeix una regla que impedeixi desplegar a producció els divendres a la tarda i els caps de setmana, amb una via d'escapament explícita per a emergències que quedi registrada.

Solucions

Solució 1.

      - name: Resoldre el digest anterior si no s ha indicat
        id: resoldre
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -Eeuo pipefail
          DIGEST="${{ inputs.digest }}"

          if [[ -z "$DIGEST" ]]; then
            echo "Sense digest indicat: es busca la versio anterior a la desplegada."

            # 1. Que corre ARA (l etiqueta que hi ha posat desplegar.sh)
            ACTUAL=$(docker inspect "mini-reservalia-${{ inputs.entorn }}" \
                       --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null \
                     | sed 's/.*@//' || echo "")
            echo "Desplegat actualment: ${ACTUAL:-(desconegut)}"

            # 2. Les versions del paquet, les mes recents primer
            mapfile -t VERSIONS < <(
              gh api "/user/packages/container/mini-reservalia/versions" \
                --jq '.[] | select(.metadata.container.tags | length > 0 or true) | .name' \
              | head -20
            )

            # 3. La primera que NO sigui l actual
            for v in "${VERSIONS[@]}"; do
              if [[ "$v" != "$ACTUAL" ]]; then DIGEST="$v"; break; fi
            done

            [[ -n "$DIGEST" ]] || { echo "::error::No s ha trobat cap versio anterior"; exit 1; }
            echo "Versio anterior resolta: $DIGEST"
          fi

          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

I fes servir ${{ steps.resoldre.outputs.digest }} als passos següents. Canvia també l'input a required: false.

Una versió més robusta no consulta el registre sinó un historial de desplegaments que el mateix cd.yml manté: un fitxer desplegaments.jsonl en una branca estat, o l'API de Deployments de GitHub (gh api "repos/{owner}/{repo}/deployments?environment=produccio"), que ja registra cada desplegament amb la seva ref. La diferència importa: el registre et diu quines imatges existeixen; l'historial et diu què s'ha desplegat i en quin ordre, que és la pregunta real.

Solució 2.

nginx-canary.conf:

upstream mini_reservalia {
    # El pes reparteix les peticions: 9 de cada 10 a l estable.
    server host.docker.internal:3002 weight=9;   # estable
    server host.docker.internal:3003 weight=1;   # canary (10 %)
}

server {
    listen 8080;
    location / {
        proxy_pass http://mini_reservalia;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        # Si la canary falla, nginx la treu del pool i reintenta a l estable:
        # l usuari no veu l error. Es el "circuit breaker" del pobre.
        proxy_next_upstream error timeout http_502 http_503;
    }
}
  canary:
    name: Desplegament canary
    runs-on: ubuntu-latest
    needs: [preparar, staging]
    environment: produccio
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }

      - name: 1. Aixecar la canary al costat de l estable
        run: ./scripts/desplegar.sh
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
          ENTORN: canary
          PORT_AMFITRIO: '3003'

      - name: 2. Balancejador al 10 %
        run: |
          docker rm -f balancejador 2>/dev/null || true
          docker run -d --name balancejador -p 8080:8080 \
            --add-host host.docker.internal:host-gateway \
            -v "$PWD/nginx-canary.conf:/etc/nginx/conf.d/default.conf:ro" \
            nginx:alpine
          sleep 3

      - name: 3. Observar 2 minuts i mesurar la taxa d error
        id: observar
        run: |
          set -Eeuo pipefail
          FI=$(( $(date +%s) + 120 ))
          TOTAL=0; ERRORS=0
          while [ "$(date +%s)" -lt "$FI" ]; do
            CODI=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
                      "http://localhost:8080/api/forats?data=2026-03-02" || echo 000)
            TOTAL=$((TOTAL+1))
            [[ "$CODI" == "200" ]] || ERRORS=$((ERRORS+1))
            sleep 1
          done
          TAXA=$(awk "BEGIN {printf \"%.2f\", $ERRORS*100/$TOTAL}")
          echo "Peticions: $TOTAL · Errors: $ERRORS · Taxa: ${TAXA}%"
          echo "taxa=$TAXA" >> "$GITHUB_OUTPUT"
          echo "### Canary: ${TAXA}% d error en $TOTAL peticions" >> "$GITHUB_STEP_SUMMARY"
          # Llindar de l 1 %: per sobre, no promociona.
          awk "BEGIN {exit !($TAXA > 1.0)}" && { echo "::error::Taxa d error per sobre de l 1 %"; exit 1; }

      - name: 4a. Promocionar al 100 %
        if: success()
        run: |
          ./scripts/desplegar.sh
          docker rm -f mini-reservalia-canary balancejador || true
        env:
          IMATGE: ${{ needs.preparar.outputs.imatge }}
          ENTORN: produccio
          PORT_AMFITRIO: '3002'

      - name: 4b. Retirar la canary
        if: failure()
        run: |
          echo "::warning::Canary retirada. Produccio continua amb la versio anterior."
          docker logs --tail 100 mini-reservalia-canary || true
          docker rm -f mini-reservalia-canary balancejador || true

El que ensenya aquest exercici, i que la 03-04 explicava en teoria: el canary no és «desplegar a poc a poc», és «desplegar i mesurar». Sense el pas 3 —una mètrica, un llindar i una decisió automàtica— tens un desplegament lent, no un canary. I fixa't que la fallada del pas 3 deixa producció intacta: el 90 % del trànsit no ha vist mai la versió nova.

Solució 3.

      - name: Finestra de desplegament
        if: inputs.emergencia != true
        run: |
          set -Eeuo pipefail
          # El runner va en UTC; el convertim a l hora local de l equip.
          export TZ='Europe/Madrid'
          DIA=$(date +%u)      # 1=dilluns ... 7=diumenge
          HORA=$(date +%H)
          ARA=$(date '+%A %H:%M %Z')

          bloquejar() {
            {
              echo "## ⛔ Desplegament bloquejat per la finestra"
              echo ""
              echo "**Moment:** $ARA"
              echo "**Motiu:** $1"
              echo ""
              echo "La finestra permesa es **de dilluns a dijous de 09:00 a 17:00** i"
              echo "**divendres de 09:00 a 13:00**."
              echo ""
              echo "Per a una emergencia real, rellanca el flux de treball amb"
              echo "\`emergencia: true\` i un motiu. Quedara registrat."
            } >> "$GITHUB_STEP_SUMMARY"
            echo "::error::Fora de la finestra de desplegament: $1"
            exit 1
          }

          [ "$DIA" -ge 6 ] && bloquejar "cap de setmana"
          [ "$DIA" -eq 5 ] && [ "$HORA" -ge 13 ] && bloquejar "divendres a la tarda"
          { [ "$HORA" -lt 9 ] || [ "$HORA" -ge 17 ]; } && bloquejar "fora de l horari laboral"

          echo "✅ Dins de la finestra de desplegament ($ARA)." >> "$GITHUB_STEP_SUMMARY"

      - name: Registrar l us de la via d escapament
        if: inputs.emergencia == true
        run: |
          {
            echo "## 🚨 DESPLEGAMENT D EMERGENCIA"
            echo ""
            echo "| Camp | Valor |"
            echo "|---|---|"
            echo "| Autoritzat per | @${{ github.actor }} |"
            echo "| Motiu | ${{ inputs.motiu_emergencia }} |"
            echo "| Moment (UTC) | $(date -u) |"
            echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
            echo ""
            echo "> Aquest desplegament s ha saltat la finestra. S ha de revisar a la retrospectiva."
          } >> "$GITHUB_STEP_SUMMARY"
          echo "::warning::Desplegament d emergencia fora de finestra per @${{ github.actor }}"

Amb els inputs corresponents:

  workflow_dispatch:
    inputs:
      emergencia:
        description: 'Saltar-se la finestra de desplegament (queda registrat)'
        type: boolean
        default: false
      motiu_emergencia:
        description: 'Obligatori si emergencia = true'
        type: string

La discussió que obre aquest exercici és més interessant que el codi, i convé tenir-la clara. Les finestres de desplegament són un antipatró en un equip amb bon CD: si fa por desplegar el divendres, el problema no és el divendres, és el desplegament. Amb rollback en 90 segons i smoke tests automàtics, un divendres és un dia com un altre, i les dades de l'informe State of DevOps són consistents en això: els equips d'elit despleguen quan cal.

Dit això, la finestra és una mesura de transició raonable mentre l'equip construeix confiança, i també és un requisit de negoci legítim en alguns contextos (una plataforma de reserves pot no voler tocar res un dissabte al matí). Si la poses, posa-li data de revisió i una via d'escapament registrada com la de dalt: una restricció sense escapament s'acaba saltant de maneres pitjors (desplegar a mà per SSH), i una restricció sense data de revisió es torna permanent per inèrcia.

Repte opcional

Substitueix l'aprovació manual de producció per una porta automàtica basada en dades: que producció es desplegui sola si el smoke test de staging passa i la taxa d'error de staging dels últims 10 minuts és inferior al 0,5 % i el canvi no toca fitxers marcats com a sensibles (migracions, configuració de seguretat); i que demani aprovació humana només en cas contrari. És el pas de «desplegament continu amb porta» a «desplegament continu amb porta condicional», i és el que fan els equips que despleguen 50 vegades al dia sense que ningú aprovi res. Necessitaràs les mètriques de la 07-04, així que guarda-ho per a després.

Què has construït

  • Un Dockerfile multietapa amb usuari no-root, HEALTHCHECK, metadades OCI i .dockerignore.
  • Publicació a ghcr.io des del pipeline amb credencial efímera, memòria cau de capes i attestació de procedència.
  • Separació CI/CD amb workflow_run i la guarda que impedeix desplegar una CI en vermell.
  • Dos Environmentsstaging automàtic i produccio amb revisor requerit— i l'experiència de veure el pipeline esperant-te.
  • Un script de desplegament idempotent, executable en tres destinacions diferents sense canvis, que rebutja les etiquetes, desa el digest anterior i espera a healthy.
  • Un smoke test amb reintents que verifica la versió desplegada i comprova que els errors continuen sent errors.
  • Promoció per digest amb verificació explícita que producció desplega exactament el que s'ha validat, sense reconstruir.
  • Un rollback.yml cronometrat que reverteix en menys de dos minuts.
  • La comprovació que un bug que la CI no detecta s'atura a staging i no arriba mai a producció.

Conclusió

El circuit està tancat: un commit pot arribar a producció sense que ningú executi cap ordre a mà, i pot tornar enrere igual de ràpid. Has vist la peça que fa que tot això sigui segur i que es resumeix en una frase: el mateix artefacte, identificat pel seu contingut, recorre tots els entorns, i cada pas comprova que efectivament és el mateix. Sense aquesta cadena, staging no valida res i el rollback és una loteria.

Però hi ha una pregunta que el teu pipeline encara no sap respondre, i és la que més importa. El smoke test et diu que el servei ha respost correctament durant els trenta segons posteriors al desplegament. I al cap de vint minuts? I quan entra el trànsit de debò? I si la versió nova funciona però és tres vegades més lenta? I quantes vegades has desplegat aquesta setmana, quant ha trigat cada canvi des del commit fins a producció, i quants d'aquests desplegaments han acabat en rollback? Ara mateix l'única resposta honesta és «no ho sé», i un pipeline que no sap si ha millorat les coses és un pipeline que no es pot defensar davant de ningú.

A la 07-04 instrumentes el sistema. Afegiràs a src/servidor.js el mesurament de la latència i el recompte per codi d'estat, exposaràs /metriques en format Prometheus sense dependències, aixecaràs Prometheus i Grafana amb docker compose amb un panell versionat al repositori, definiràs un SLO amb el seu pressupost d'error calculat amb aritmètica explícita, escriuràs una alerta per símptoma i la dispararàs expressament ficant un sleep en un endpoint, marcaràs els desplegaments al panell per veure la correlació entre «hem desplegat» i «ha empitjorat», calcularàs les quatre mètriques DORA del teu propi repositori amb un job programat, i —tancant el bucle que aquesta lliçó deixa obert— faràs que el cd.yml sondegi les mètriques després de desplegar i dispari el rollback sol si l'error supera el llindar.

Curs de CI/CD: Integració i Desplegament Continu

Mòdul 1: Introducció al CI/CD

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats