Skip to main content

Control de Ejecución

Hasta ahora hemos conseguido escribir pipelines que tienen un inicio y fin y detienen la ejecución si algo falla, incluyendo los jobs dependientes. Este es el comportamiento por defecto y tiene sentido, pero a veces queremos continuar la ejecución aunque una etapa falle o si un conjunto de etapas fallan.

Un ejemplo clásico es cuando aplicamos un lint para ver si el código está bien indentado. Si no pasa el lint fallará, pero el código está funcionando y podríamos continuar la ejecución para buscar más errores durante el desarrollo y después corregimos todo.

Podríamos sumar eso con análisis de vulnerabilidad y después con análisis de código. Podría fallar en las 3 etapas y pasar en las pruebas, generar el build, pero no queremos hacer el deploy. A partir de ahí con los errores generados podemos crear nuevas tareas para que los desarrolladores corrijan los problemas e intentarlo nuevamente.

Si tenemos varios entornos, de desarrollo de staging y producción no necesitamos ejecutar todo el pipeline cuando sea para producción si viene mergeado de la branch de staging donde hicieron todas las posibles pruebas, sería pérdida de tiempo. Solo necesitamos hacer el deploy en el entorno.

Podemos poner condiciones en los jobs y en los steps, pero solo en los steps podemos ignorar si ocurre un error, un job necesita finalizar con éxito ignorando errores de steps.

Las expresiones se usan para crear una condición.

alt text

Volvamos a la aplicación en node usando el flujo siguiente con 4 jobs.

name: Website Deployment
on:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Lint code
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Test code
run: npm run test
- name: Upload test report
uses: actions/upload-artifact@v4
with:
name: test-report
path: test.json
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Build website
id: build-website
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Get build artifacts
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Output contents
run: ls
- name: Deploy
run: echo "Deploying..."

Tenemos 4 steps usando la caché siendo que:

  1. Los jobs lint y test se ejecutan en paralelo pues no tienen dependencia, pero van a usar la misma caché, ¿verdad? ¿Cómo si no tienen dependencia?
  2. El test es un prerrequisito para build, pero el lint no. Test genera un informe como artefacto y también hace el upload de este único archivo.
  3. El build es un prerrequisito para deploy, después de todo hace la descarga del artefacto generado por build.

Vamos a poner algunas condiciones.

  • Solo queremos que haga el upload si el test falla para hacer análisis. Si todo va bien no lo necesitamos. Para eso necesitamos echar un vistazo al contexto de steps.

      test:
    runs-on: ubuntu-latest
    steps:
    ...
    - name: Test code
    id: test-code # necesitamos crear una referencia para él
    run: npm run test

    - name: Upload test report
    uses: actions/upload-artifact@v4
    # Usamos el outcome en lugar de conclusion pues queremos hacer el if antes de aplicar un continue-on-error en el step anterior
    # if: steps.test-code.outcome == 'failure'
    # Incluso con la condición comentada arriba github continuará con el comportamiento por defecto de que si un step falla detendrá el job
    # La función especial failure resuelve el problema, hablaremos a continuación
    if: failure() && steps.test-code.outcome == 'failure'
    with:
    name: test-report
    path: test.json

Existen 4 funciones especiales que cambian el comportamiento por defecto del workflow y deben sumarse lógicamente a la condición para cambiar el comportamiento por defecto del workflow.

alt text

  • Failure() Siempre devuelve true si cualquier step o job anterior falla
  • success() devuelve true si ningún step anterior falla.
  • always() Siempre devuelve true forzando la ejecución incluso si el workflow es cancelado.
  • cancelled() devuelve true si el WORKFLOW es cancelado.

Cuando añadimos el failure() && sumará la lógica si el step que apuntamos anteriormente falló entonces da true y se ejecutará.

Vamos a ejecutar un workflow con el test sin fallo y otro con fallo y ver el informe. En la primera imagen podemos observar que no tenemos el informe del test como artefacto y todo pasó normalmente.

alt text

Ahora forzando un error en test no ejecutó el build como esperábamos pero subió el informe del test como artefacto alt text

If

If también puede usarse para jobs.

Si queremos crear un último job que solo se ejecute si algún otro job falla y hacemos esto...

jobs:
lint:
...
test:
...
build:
needs: [test]
deploy:
needs: [build]
report:
if: failure()
runs-on: ubuntu-latest
steps:
- name: Output Info
run: |
echo "Haz algo cuando falle"

Hará un skip desde el principio pues se ejecutará en paralelo con lint y test identificando ya que nadie falló justo al inicio. Para que funcione necesitaría poner el needs para lint y deploy. En el caso del deploy tiene dependencia de build y de test, así que cualquiera que falle, falla el deploy.

jobs:
lint:
...
test:
...
build:
needs: [test]
deploy:
needs: [build]
report:
needs: [lint, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- name: Output Info
run: |
echo "Haz algo cuando falle, imprimir el contexto de github"
echo "${{ toJSON(github) }}"

alt text

Ahora vamos a mejorar la caché. En lugar de hacer la caché de ~/.npm para ganar velocidad en el npm ci podemos hacer la caché de node_modules y si la caché se restaura ni siquiera necesitamos ejecutar el comando npm ci para instalar las dependencias.

En la documentación de la action cache tenemos esto.

alt text

name: Website Deployment
on:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache # Para referenciar este step
uses: actions/cache@v4
with:
path: node_modules # Cambiamos la carpeta
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
# Si no coincidió con una caché entonces ejecuta
# steps.cache.outputs.cache-hit se convierte en string por eso 'true'
# todos los bloques idénticos en los jobs subsiguientes ya fueron modificados
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci
- name: Lint code
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: node_modules
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci
- name: Test code
id: test-code
run: npm run test
- name: Upload test report
uses: actions/upload-artifact@v4
if: failure() && steps.test-code.outcome == 'failure'
with:
name: test-report
path: test.json
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Get code
uses: actions/checkout@v4
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: node_modules
key: deps-node-modules-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build website
id: build-website
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Get build artifacts
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Output contents
run: ls
- name: Deploy
run: echo "Deploying..."
report:
needs: [lint, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- name: Output Info
run: |
echo "Haz algo cuando falle, imprimir el contexto de github"
echo "${{ github }}"

alt text

También podemos observar que report no se ejecutó, pues ningún job falló.

Ignorar Errores con continue-on-error

Usar el continue-on-error simplemente marca como success un step o job aunque este falle. Si hiciéramos esto aquí aunque el test falle, se ejecutará el build y el deploy incluyendo el upload del artefacto de error.

  test:
steps:
...
- name: Test code
id: test-code
run: npm run test
continue-on-error: true
- name: Upload test report
uses: actions/upload-artifact@v4
if: steps.test-code.outcome == 'failure'
with:
name: test-report
path: test.json
...

Matrix

El uso de una matriz permite que ejecutes un job varias veces (en paralelo) con diferentes entradas.

Un buen ejemplo para este escenario sería una batería de pruebas de versión para una misma build, o compilar varias veces un mismo binario para diferentes plataformas.

name: Matrix Demo

on: push

jobs:
build:
# continue-on-error: true
strategy:
matrix:
node-version: [12,14,16]
operating-systems: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.operating-systems}}
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Build website
run: npm run build

alt text

En este caso intentó las posibles combinaciones entre los valores generando 6 jobs al mismo tiempo. Si cualquiera de ellos falla la matrix será cancelada e ignorada.

Si queremos que continúe de todos modos podemos usar el continue-on-error y tendremos esta salida.

alt text

alt text

Podríamos solo una combinación específica en el caso ubuntu-latest y versión 18 sin el windows.

...
jobs:
build:
strategy:
matrix:
# windows con 12 14 16
# linux con 12 14 y 16
node-version: [12,14,16]
operating-systems: [ubuntu-latest, windows-latest]
include:
# sumado al linux 18
- node-version: 18
operating-systems: ubuntu-latest
# eliminado windows 12
exclude:
- node-version: 12
operating-systems: windows-latest
# total
# windows 14 16
# linux 12 14 16 18 # <<<<<<
...

Reutilización de Workflow

En realidad cuando usamos actions estamos reutilizando un job, pero podemos reutilizar un workflow completo.

Vamos a crear un workflow en que el evento sea workflow_call.

.github/worfkflows/deploy.yaml

name: Deploy
on:
workflow_call:
inputs:
artifact-name:
description: Nombre del artefacto que será desplegado
required: false
# si no se pasa ningún input se usará el nombre dist por eso el required quedó en false
default: dist
type: string
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Get build artifacts
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
- name: List Files
run: ls
- name: Deploying
run: echo "Deploying...."

Ahora podemos usar ese workflow en nuestro workflow principal llamando a este workflow en medio del proceso.

name: Website Deployment
on:
push:
branches:
- main
jobs:
lint:
...
test:
...
build:
...
deploy:
# observa que no tenemos steps pues estamos llamando a un workflow completo.
needs: build
uses: ./.github/workflows/deploy.yaml
with:
artifact-name: dist-files
report:
...

alt text

alt text

También es posible pasar secrets a workflows reutilizables.

Así como tenemos los inputs, tenemos los outputs.