CI/CD

Com configurar pipelines de CI/CD per a Laravel: GitHub Actions, GitLab CI, tests automatitzats, anàlisi de codi i desplegament continu.

Què és CI/CD?#

CI/CD són les sigles de Continuous Integration (Integració Contínua) i Continuous Deployment (Desplegament Continu). Són dues pràctiques que, combinades, automatitzen tot el procés des que un desenvolupador puja codi fins que aquest codi s'executa en producció. La idea fonamental és simple: cada canvi al codi ha de passar per una sèrie de verificacions automàtiques abans d'arribar a producció, i el desplegament en si també ha de ser automàtic.

La integració contínua s'encarrega de verificar que el codi nou no trenca res. Cada vegada que algú fa push a una branca o obre un pull request, el pipeline executa automàticament els tests, comprova l'estil del codi, fa anàlisi estàtica i verifica que l'aplicació es pot construir correctament. Si alguna d'aquestes verificacions falla, el desenvolupador rep una notificació immediata i pot corregir el problema abans que arribi a la branca principal.

El desplegament continu va un pas més enllà: quan el codi passa totes les verificacions i es fusiona a la branca principal, es desplega automàticament a producció. Això elimina el procés manual de desplegament, que és propens a errors humans, i permet desplegar amb confiança desenes de vegades al dia si cal.

Per què és important?#

Sense CI/CD, el procés típic és: un desenvolupador puja codi, algú el revisa manualment, algú altre recorda executar els tests al seu ordinador, i quan toca desplegar, algú es connecta al servidor per SSH i executa una sèrie de comandes que espera recordar correctament. Cada pas manual és una oportunitat perquè alguna cosa surti malament.

Amb CI/CD, tot això és automàtic i repetible. Els tests s'executen sempre, sense excepcions. L'estil del codi es verifica a cada push. L'anàlisi estàtica detecta bugs potencials abans que arribin a producció. I el desplegament es fa exactament de la mateixa manera cada vegada, sense dependre de la memòria o la disponibilitat de ningú.

Les etapes d'un pipeline#

Un pipeline de CI/CD típic per a una aplicació Laravel segueix aquestes etapes, cadascuna construint sobre l'anterior:

  1. Lint (estil de codi) - Verifica que el codi segueix les convencions d'estil definides per l'equip. Laravel Pint és l'eina estàndard.
  2. Anàlisi estàtica - Detecta bugs potencials sense executar el codi. PHPStan amb Larastan és l'opció recomanada.
  3. Tests - Executa els tests unitaris i funcionals per verificar que l'aplicació funciona correctament.
  4. Build - Compila els assets del frontend i verifica que l'aplicació es pot construir.
  5. Deploy - Desplega l'aplicació a producció (o a un entorn de staging per a pull requests).

GitHub Actions#

GitHub Actions és el sistema de CI/CD integrat a GitHub. Si el teu repositori està a GitHub, és l'opció més natural perquè no cal configurar cap servei extern: els workflows s'executen directament als servidors de GitHub. Els workflows es defineixen en fitxers YAML dins del directori .github/workflows/ del repositori.

Pipeline complet#

El workflow següent és un pipeline complet per a una aplicació Laravel. Executa Pint per verificar l'estil del codi, Larastan per a l'anàlisi estàtica, els tests amb una base de dades MySQL real i verifica que els assets es compilen correctament. Tot això en paral·lel quan és possible, per minimitzar el temps d'execució:

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  lint:
    name: Estil de codi
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: composer:v2
          coverage: none
 
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-
 
      - name: Instal·lar dependències
        run: composer install --prefer-dist --no-progress --no-interaction
 
      - name: Executar Pint
        run: ./vendor/bin/pint --test
 
  analysis:
    name: Anàlisi estàtica
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: composer:v2
          coverage: none
 
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-
 
      - name: Instal·lar dependències
        run: composer install --prefer-dist --no-progress --no-interaction
 
      - name: Executar PHPStan / Larastan
        run: ./vendor/bin/phpstan analyse --memory-limit=512M
 
  tests:
    name: Tests (PHP ${{ matrix.php }})
    runs-on: ubuntu-latest
 
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3']
 
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: testing
          MYSQL_USER: laravel
          MYSQL_PASSWORD: password
          MYSQL_ROOT_PASSWORD: password
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h localhost"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
 
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd="redis-cli ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, pdo_mysql, redis, gd, intl, zip, bcmath
          tools: composer:v2
          coverage: xdebug
 
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-php${{ matrix.php }}-${{ hashFiles('composer.lock') }}
          restore-keys: composer-php${{ matrix.php }}-
 
      - name: Instal·lar dependències
        run: composer install --prefer-dist --no-progress --no-interaction
 
      - name: Preparar entorn
        run: |
          cp .env.example .env
          php artisan key:generate
 
      - name: Executar migracions
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: laravel
          DB_PASSWORD: password
        run: php artisan migrate --force
 
      - name: Executar tests
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: laravel
          DB_PASSWORD: password
          REDIS_HOST: 127.0.0.1
          REDIS_PORT: 6379
        run: php artisan test --parallel --coverage-clover coverage.xml
 
      - name: Pujar cobertura
        if: matrix.php == '8.3'
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml
          token: ${{ secrets.CODECOV_TOKEN }}
 
  assets:
    name: Compilar assets
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
 
      - name: Instal·lar dependències npm
        run: npm ci --no-audit --no-fund
 
      - name: Compilar assets
        run: npm run build
 
      - name: Verificar que no hi ha canvis sense cometre
        run: |
          if [ -n "$(git status --porcelain public/build)" ]; then
            echo "Els assets compilats no estan actualitzats. Executa 'npm run build' i puja els canvis."
            exit 1
          fi
 
  security:
    name: Auditoria de seguretat
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: composer:v2
          coverage: none
 
      - name: Auditar dependències PHP
        run: composer audit
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
 
      - name: Auditar dependències npm
        run: npm audit --audit-level=high

Analitzem els aspectes clau d'aquest workflow.

Matrix strategy#

La secció strategy.matrix permet executar els tests amb múltiples versions de PHP en paral·lel. Amb fail-fast: false, si els tests fallen amb PHP 8.2, els tests amb PHP 8.3 continuen executant-se. Això és útil per saber si un error és específic d'una versió de PHP o afecta totes les versions. En el nostre cas, testem amb PHP 8.2 i 8.3 per assegurar compatibilitat.

Service containers#

GitHub Actions permet arrencar serveis Docker (MySQL, Redis, PostgreSQL) com a contenidors al costat del runner. Aquests serveis són accessibles via 127.0.0.1 amb els ports definits. Els health checks garanteixen que el servei està llest abans que els tests comencin a executar-se. Fixeu-vos que les credencials del servei MySQL coincideixen amb les variables d'entorn dels passos que executen els tests.

Cache de dependències#

La cache de Composer i npm és fonamental per al rendiment del pipeline. Sense cache, cada execució ha de descarregar totes les dependències des de zero, cosa que pot trigar minuts. Amb la clau de cache basada en el hash de composer.lock, la cache s'invalida automàticament quan les dependències canvien. L'ús de restore-keys permet reutilitzar una cache parcial si el hash exacte no coincideix.

Tests en paral·lel#

El flag --parallel de php artisan test executa els tests en múltiples processos simultàniament, reduint significativament el temps d'execució. En un projecte amb centenars de tests, la diferència pot ser de minuts. Laravel gestiona automàticament la creació de bases de dades separades per a cada procés paral·lel.

Si els teus tests no necessiten una base de dades MySQL completa, pots fer servir SQLite en memòria per accelerar encara més el pipeline. Configura DB_CONNECTION=sqlite i DB_DATABASE=:memory: a les variables d'entorn. Alguns tests que depenen de funcionalitats específiques de MySQL (com JSON columns o fulltext search) poden fallar amb SQLite, així que avalua-ho segons el teu projecte.

Job de desplegament#

Un cop els tests passen, el pas de desplegament posa el codi en producció. Hi ha diverses estratègies de desplegament, cadascuna amb els seus avantatges. Afegeix un job de deploy al workflow anterior:

Desplegament via webhook (Laravel Forge)#

La manera més senzilla de desplegar amb Forge és disparar un webhook que activa l'script de desplegament configurat al panel de Forge:

  deploy-forge:
    name: Desplegar a Forge
    runs-on: ubuntu-latest
    needs: [lint, analysis, tests, assets, security]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 
    steps:
      - name: Disparar desplegament a Forge
        run: |
          curl -s -X POST \
            "${{ secrets.FORGE_DEPLOY_WEBHOOK }}" \
            --max-time 30

El camp needs assegura que el desplegament només s'executa si tots els jobs anteriors (lint, analysis, tests, assets, security) han passat. La condició if limita el desplegament a pushes directes a la branca main, excloent pull requests.

Desplegament via SSH#

Si no fas servir Forge, pots desplegar connectant-te directament al servidor via SSH. Necessites configurar una clau SSH privada com a secret de GitHub:

  deploy-ssh:
    name: Desplegar via SSH
    runs-on: ubuntu-latest
    needs: [lint, analysis, tests, assets, security]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 
    steps:
      - name: Configurar SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
 
      - name: Desplegar
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY'
            cd /var/www/laravelandorra.com
            php artisan down --retry=60
 
            git pull origin main
 
            composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
            npm ci --no-audit --no-fund && npm run build
 
            php artisan migrate --force
            php artisan optimize
            php artisan queue:restart
 
            php artisan up
          DEPLOY

L'script de desplegament segueix un ordre molt concret i important. Primer posa l'aplicació en mode manteniment (artisan down) perquè els usuaris vegin una pàgina de manteniment en lloc d'errors. Després actualitza el codi, instal·la les dependències, executa les migracions, optimitza les caches i reinicia els workers. Finalment, torna a activar l'aplicació (artisan up). L'opció --retry=60 indica als clients que tornin a intentar la petició després de 60 segons.

Desplegament d'imatge Docker#

Si desplegues amb Docker, el pipeline construeix la imatge, la puja al registre i actualitza el servei en producció:

  deploy-docker:
    name: Desplegar imatge Docker
    runs-on: ubuntu-latest
    needs: [lint, analysis, tests, assets, security]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 
    permissions:
      packages: write
      contents: read
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Iniciar sessió a GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Construir i pujar imatge
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
 
      - name: Desplegar al servidor
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY'
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}
            cd /opt/laravel
            docker compose up -d --no-deps app worker scheduler
          DEPLOY

L'avantatge de desplegar amb Docker és que la imatge que s'executa en producció és exactament la mateixa que ha passat tots els tests. No hi ha diferències d'entorn, no hi ha dependències que s'instal·lin de manera diferent, no hi ha assets que es compilin amb versions de Node diferents. El que funciona al pipeline funciona en producció, punt.

Badges d'estat#

Afegeix badges al README.md del teu repositori perquè tothom pugui veure d'un cop d'ull si el pipeline passa correctament:

![CI](https://github.com/el-teu-usuari/el-teu-repo/actions/workflows/ci.yml/badge.svg)

Quan el pipeline falla, el badge es torna vermell i qualsevol persona que visiti el repositori sabrà que hi ha un problema. Això crea una pressió positiva per mantenir el pipeline en verd.

GitLab CI#

Si el teu repositori està a GitLab, el sistema de CI/CD integrat es configura amb un fitxer .gitlab-ci.yml a l'arrel del projecte. GitLab CI té una filosofia lleugerament diferent de GitHub Actions: els jobs s'organitzen en stages (etapes) que s'executen seqüencialment, i tots els jobs dins d'un stage s'executen en paral·lel.

# .gitlab-ci.yml
 
stages:
  - quality
  - test
  - build
  - deploy
 
variables:
  MYSQL_DATABASE: testing
  MYSQL_USER: laravel
  MYSQL_PASSWORD: password
  MYSQL_ROOT_PASSWORD: password
  DB_HOST: mysql
  DB_DATABASE: testing
  DB_USERNAME: laravel
  DB_PASSWORD: password
 
# Cache global de Composer
cache:
  key:
    files:
      - composer.lock
  paths:
    - vendor/
  policy: pull
 
# Job base per a PHP
.php-setup:
  image: php:8.3
  before_script:
    - apt-get update && apt-get install -y git unzip libzip-dev libpng-dev libicu-dev
    - docker-php-ext-install pdo_mysql zip gd intl bcmath
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-progress --no-interaction
 
# ==========================================
# Stage: Quality
# ==========================================
 
lint:
  extends: .php-setup
  stage: quality
  cache:
    key:
      files:
        - composer.lock
    paths:
      - vendor/
    policy: pull-push
  script:
    - ./vendor/bin/pint --test
 
phpstan:
  extends: .php-setup
  stage: quality
  script:
    - ./vendor/bin/phpstan analyse --memory-limit=512M
 
security-audit:
  extends: .php-setup
  stage: quality
  script:
    - composer audit
  allow_failure: true
 
# ==========================================
# Stage: Test
# ==========================================
 
test:php83:
  extends: .php-setup
  stage: test
  image: php:8.3
  services:
    - name: mysql:8.0
      alias: mysql
    - name: redis:7-alpine
      alias: redis
  variables:
    REDIS_HOST: redis
    REDIS_PORT: "6379"
  script:
    - cp .env.example .env
    - php artisan key:generate
    - php artisan migrate --force
    - php artisan test --parallel --coverage-text
  coverage: '/Lines:\s+(\d+\.\d+)%/'
 
test:php82:
  extends: test:php83
  image: php:8.2
 
# ==========================================
# Stage: Build
# ==========================================
 
build-assets:
  stage: build
  image: node:20-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  script:
    - npm ci --no-audit --no-fund
    - npm run build
  artifacts:
    paths:
      - public/build/
    expire_in: 1 hour
 
build-docker:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  only:
    - main
    - tags
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
 
# ==========================================
# Stage: Deploy
# ==========================================
 
deploy-staging:
  stage: deploy
  image: alpine:latest
  only:
    - develop
  before_script:
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $STAGING_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $SSH_USER@$STAGING_HOST "cd /var/www/staging && git pull && composer install --no-dev --optimize-autoloader && php artisan migrate --force && php artisan optimize"
  environment:
    name: staging
    url: https://staging.laravelandorra.com
 
deploy-production:
  stage: deploy
  image: alpine:latest
  only:
    - main
  when: manual
  before_script:
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $PRODUCTION_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $SSH_USER@$PRODUCTION_HOST "cd /var/www/production && git pull && composer install --no-dev --optimize-autoloader && php artisan migrate --force && php artisan optimize && php artisan queue:restart"
  environment:
    name: production
    url: https://laravelandorra.com

Hi ha diverses diferències importants respecte a GitHub Actions que val la pena destacar.

Templates amb extends#

GitLab CI permet definir templates (prefixats amb un punt, com .php-setup) que altres jobs poden estendre. Això evita la duplicació de configuració entre jobs que comparteixen la mateixa configuració base. L'equivalent a GitHub Actions seria un workflow reutilitzable o un action personalitzat.

Cache global#

La cache de GitLab CI es pot definir globalment i aplicar a tots els jobs. La política pull descarrega la cache però no la puja (útil per a la majoria de jobs), mentre que pull-push descarrega i puja la cache actualitzada (útil per al primer job que instal·la les dependències). Això assegura que la cache es genera una sola vegada i es comparteix entre tots els jobs.

Desplegament manual vs automàtic#

El desplegament a staging es fa automàticament quan es puja codi a la branca develop. El desplegament a producció, en canvi, està configurat amb when: manual, cosa que significa que requereix que algú cliqui un botó al panel de GitLab per iniciar-lo. Això dóna un control addicional per a l'entorn de producció: el codi ha passat totes les verificacions automàtiques, però un humà pren la decisió final de desplegar.

Environments#

GitLab CI suporta environments, que permeten veure al panel de GitLab quina versió del codi s'executa a cada entorn (staging, producció) i accedir directament a la URL del servei.

Tasques comuns de CI#

Verificació d'estil de codi amb Pint#

Laravel Pint és el formatejador de codi oficial de Laravel. El flag --test verifica que el codi segueix les convencions sense modificar-lo. Si troba alguna desviació, surt amb un codi d'error i el pipeline falla:

./vendor/bin/pint --test

Si vols que Pint corregeixi automàticament l'estil i pugui els canvis, pots afegir un pas addicional al pipeline:

      - name: Corregir estil amb Pint
        if: failure()
        run: |
          ./vendor/bin/pint
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add -A
          git diff-index --quiet HEAD || git commit -m "style: corregir estil amb Pint"
          git push

La correcció automàtica d'estil al pipeline és convenient però pot ser problemàtica si no s'utilitza amb cura. Pot crear conflictes amb pull requests oberts i dificulta la revisió de codi perquè barreja canvis funcionals amb canvis d'estil. Molts equips prefereixen que els desenvolupadors executin Pint localment abans de fer push.

Anàlisi estàtica amb Larastan#

Larastan és una extensió de PHPStan específica per a Laravel. Entén les facades, les relacions d'Eloquent, les col·leccions i molts altres patrons de Laravel, i pot detectar errors que PHPStan sol no detectaria:

# Instal·lar Larastan
composer require --dev larastan/larastan
 
# Executar l'anàlisi
./vendor/bin/phpstan analyse --memory-limit=512M

La configuració de Larastan es defineix al fitxer phpstan.neon a l'arrel del projecte:

includes:
    - vendor/larastan/larastan/extension.neon
 
parameters:
    paths:
        - app/
    level: 6
    ignoreErrors: []
    excludePaths: []

El nivell 6 és un bon equilibri entre rigor i pragmatisme. Els nivells més alts (7, 8, 9) detecten més errors potencials però també generen més falsos positius, cosa que pot ser frustrant i contraproduent. Comença amb un nivell baix i augmenta-ho gradualment a mesura que corregeixis els errors existents.

Auditoria de seguretat#

La comanda composer audit comprova si alguna de les dependències instal·lades té vulnerabilitats de seguretat conegudes. És una verificació ràpida i valuosa que hauria de formar part de tots els pipelines:

# Auditar dependències PHP
composer audit
 
# Auditar dependències npm
npm audit --audit-level=high

El flag --audit-level=high de npm ignora les vulnerabilitats de severitat baixa i mitjana, que sovint són falsos positius o afecten funcionalitats que l'aplicació no utilitza. Només les vulnerabilitats de severitat alta fan fallar el pipeline.

Cobertura de tests#

La cobertura de tests mesura quin percentatge del codi s'executa durant els tests. No és una mètrica perfecta (100% de cobertura no significa 100% de qualitat), però és un indicador útil per identificar parts del codi que no estan testejades:

      - name: Executar tests amb cobertura
        run: php artisan test --coverage-clover coverage.xml --coverage-text
 
      - name: Verificar cobertura mínima
        run: |
          COVERAGE=$(php -r "
            \$xml = simplexml_load_file('coverage.xml');
            \$metrics = \$xml->project->directory->totals->lines;
            echo round((\$metrics['covered'] / \$metrics['count']) * 100, 2);
          ")
          echo "Cobertura: ${COVERAGE}%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "La cobertura és inferior al 80%"
            exit 1
          fi

Tests de navegador (Dusk) al CI#

Laravel Dusk permet executar tests de navegador amb Chrome. Configurar Dusk al CI requereix una mica més de treball perquè cal un navegador Chrome headless:

  dusk:
    name: Tests de navegador
    runs-on: ubuntu-latest
    needs: [tests]
 
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: testing
          MYSQL_ROOT_PASSWORD: password
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql
 
      - name: Instal·lar dependències
        run: composer install --prefer-dist --no-progress
 
      - name: Preparar entorn
        run: |
          cp .env.example .env
          php artisan key:generate
          npm ci && npm run build
 
      - name: Instal·lar Chrome
        run: php artisan dusk:chrome-driver --detect
 
      - name: Arrencar servidor
        run: php artisan serve --port=8000 &
 
      - name: Executar Dusk
        env:
          APP_URL: http://127.0.0.1:8000
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan dusk
 
      - name: Pujar captures en cas d'error
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: dusk-screenshots
          path: tests/Browser/screenshots/

L'últim pas és especialment útil: si algun test de Dusk falla, Laravel fa una captura de pantalla del navegador en el moment de l'error. Pujant aquestes captures com a artifacts, pots veure exactament què ha fallat sense haver de reproduir l'error localment.

Estratègies de desplegament#

Desplegar amb push a main#

La estratègia més comuna. Cada push (o merge) a la branca main desplega automàticament a producció. La condició és simple:

if: github.ref == 'refs/heads/main' && github.event_name == 'push'

Aquesta estratègia requereix que la branca main estigui protegida amb regles que obliguin a passar tots els checks abans de poder fusionar. D'altra manera, un push directe a main amb codi trencat desplegaria immediatament a producció.

Desplegar amb tags o releases#

Per a equips que prefereixen un control més explícit, el desplegament es pot vincular a la creació d'un tag de Git o una release de GitHub:

on:
  release:
    types: [published]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Desplegar versió ${{ github.event.release.tag_name }}
        run: |
          echo "Desplegant la versió ${{ github.event.release.tag_name }}"
          # Comandes de desplegament

Amb aquesta estratègia, el flux és: desenvolupar a branques, fusionar a main, i quan estàs llest per desplegar, crear una release amb un tag semàntic (v1.2.3). El pipeline es dispara automàticament amb la release.

Desplegament manual#

Per a entorns on el desplegament ha de ser aprovat manualment, GitHub Actions ofereix workflow_dispatch:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Entorn de desplegament"
        required: true
        type: choice
        options:
          - staging
          - production
      confirmation:
        description: "Escriu 'DEPLOY' per confirmar"
        required: true
        type: string
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.event.inputs.confirmation == 'DEPLOY'
    environment: ${{ github.event.inputs.environment }}
    steps:
      - name: Desplegar a ${{ github.event.inputs.environment }}
        run: echo "Desplegant a ${{ github.event.inputs.environment }}"

El camp de confirmació afegeix una capa de protecció: l'operador ha d'escriure literalment "DEPLOY" per confirmar que vol desplegar. L'ús d'environment a GitHub Actions permet configurar regles d'aprovació addicionals i limitar qui pot desplegar a cada entorn.

Preview deployments per a pull requests#

Els preview deployments creen un entorn temporal per a cada pull request, permetent revisar els canvis en un entorn real abans de fusionar-los a la branca principal:

  preview:
    name: Preview deployment
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    needs: [lint, analysis, tests]
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Desplegar preview
        run: |
          # Desplegament a un entorn temporal
          # Varia segons la plataforma (Vercel, Forge, etc.)
          echo "Preview disponible a: https://pr-${{ github.event.number }}.staging.laravelandorra.com"
 
      - name: Comentar al PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Preview desplegat a: https://pr-${{ github.event.number }}.staging.laravelandorra.com`
            })

Gestió de secrets#

Els secrets (contrasenyes, claus API, tokens SSH) mai s'han d'incloure al codi font. Tant GitHub com GitLab proporcionen mecanismes segurs per emmagatzemar i accedir a secrets dins dels pipelines.

GitHub Secrets#

A GitHub, els secrets es configuren a Settings > Secrets and variables > Actions. Es poden definir a nivell de repositori, organització o environment:

# Accedir a un secret al workflow
steps:
  - name: Desplegar
    env:
      API_KEY: ${{ secrets.API_KEY }}
      SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    run: echo "Desplegant amb la clau API..."

Els secrets de GitHub estan encriptats i mai es mostren als logs del workflow. Si un step intenta imprimir un secret, GitHub el substitueix per ***. A més, els secrets dels forks no estan disponibles als pull requests per motius de seguretat, cosa que impedeix que algú pugui crear un fork, modificar el workflow i accedir als teus secrets.

GitLab CI/CD Variables#

A GitLab, les variables es configuren a Settings > CI/CD > Variables. Pots marcar-les com "Protected" (només disponibles a branques protegides) i "Masked" (ocultes als logs):

# Les variables es converteixen en variables d'entorn automàticament
deploy:
  script:
    - ssh -i $SSH_PRIVATE_KEY user@$PRODUCTION_HOST "deploy.sh"

Revisa periòdicament els secrets dels teus pipelines. Elimina els que ja no s'utilitzen, rota les claus regularment (almenys cada 90 dies) i limita l'abast de cada secret al mínim necessari. Un secret amb permisos excessius compromès pot tenir conseqüències devastadores.

Notificacions#

Rebre notificacions quan el pipeline falla és crític per reaccionar ràpidament. Si ningú se n'adona que el pipeline està en vermell, el codi trencat pot acumular-se i fer molt més difícil trobar i corregir el problema.

Notificació a Slack#

  notify:
    name: Notificar
    runs-on: ubuntu-latest
    needs: [lint, analysis, tests, assets]
    if: failure()
 
    steps:
      - name: Notificar a Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Pipeline fallit a ${{ github.repository }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Pipeline fallit* :x:\n*Repositori:* ${{ github.repository }}\n*Branca:* ${{ github.ref_name }}\n*Commit:* ${{ github.event.head_commit.message }}\n*Autor:* ${{ github.actor }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Veure detalls>"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

La condició if: failure() assegura que la notificació només s'envia quan algun job ha fallat. Això evita notificacions innecessàries per cada push exitós, que acabarien sent ignorades per l'equip.

Notificació per correu#

      - name: Notificar per correu
        if: failure()
        uses: dawidd6/action-send-mail@v3
        with:
          server_address: smtp.gmail.com
          server_port: 465
          username: ${{ secrets.MAIL_USERNAME }}
          password: ${{ secrets.MAIL_PASSWORD }}
          subject: "Pipeline fallit: ${{ github.repository }}"
          to: equip@laravelandorra.com
          from: CI/CD <ci@laravelandorra.com>
          body: |
            El pipeline ha fallit a la branca ${{ github.ref_name }}.
            Commit: ${{ github.event.head_commit.message }}
            Autor: ${{ github.actor }}
            Detalls: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

Consells pràctics#

Mantenir el pipeline ràpid#

Un pipeline lent és un pipeline que la gent intenta evitar. Si els tests triguen 20 minuts, els desenvolupadors faran push de grans blocs de canvis en lloc de commits petits i freqüents, cosa que derrota el propòsit de la integració contínua. Algunes estratègies per mantenir el pipeline ràpid:

  • Executar jobs en paral·lel: Lint, anàlisi i tests poden executar-se simultàniament perquè són independents.
  • Aprofitar la cache: Composer i npm no han de descarregar totes les dependències a cada execució.
  • Tests en paral·lel: L'opció --parallel divideix els tests entre múltiples processos.
  • SQLite per a tests ràpids: Si els tests no necessiten funcionalitats específiques de MySQL, SQLite en memòria és molt més ràpid.
  • Limitar els tests de Dusk: Els tests de navegador són lents. Reserva'ls per als fluxos crítics i no els executis a cada push, sinó només a la branca principal.

Checklist de desplegament al CI#

Afegeix un pas que verifiqui que tot està llest per al desplegament:

      - name: Checklist de desplegament
        run: |
          # Verificar que les migracions es poden executar
          php artisan migrate --pretend
 
          # Verificar que les rutes estan correctes
          php artisan route:list --json > /dev/null
 
          # Verificar que la configuració es pot cachear
          php artisan config:cache
          php artisan config:clear
 
          # Verificar que les vistes es poden compilar
          php artisan view:cache
          php artisan view:clear

Estratègia de rollback#

Sempre has de tenir un pla per revertir un desplegament que ha anat malament. La manera més senzilla amb Git és crear un tag abans de cada desplegament i, si alguna cosa falla, desplegar el tag anterior:

      - name: Crear tag de backup
        run: |
          TIMESTAMP=$(date +%Y%m%d%H%M%S)
          git tag "deploy-${TIMESTAMP}"
          git push origin "deploy-${TIMESTAMP}"
 
      - name: Desplegar
        id: deploy
        run: |
          # Comandes de desplegament
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "cd /var/www/app && ./deploy.sh"
 
      - name: Rollback en cas d'error
        if: failure() && steps.deploy.outcome == 'failure'
        run: |
          echo "Desplegament fallit. Fent rollback..."
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'ROLLBACK'
            cd /var/www/app
            git checkout $(git describe --tags --abbrev=0 HEAD~1)
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan optimize
            php artisan queue:restart
          ROLLBACK

Amb Docker, el rollback és encara més senzill: simplement desplega la imatge anterior que ja existeix al registre de contenidors:

# Rollback a la versió anterior
docker compose pull --policy always
docker tag ghcr.io/el-teu-repo/app:sha-anterior ghcr.io/el-teu-repo/app:latest
docker compose up -d

Un pipeline de CI/CD és un sistema viu que evoluciona amb el projecte. Comença amb quelcom senzill (tests + desplegament) i afegeix verificacions a mesura que el projecte creixi. No cal implementar tot el que s'ha descrit en aquest capítol des del primer dia. L'important és que cada canvi de codi passi per almenys alguna verificació automàtica abans d'arribar a producció.