Construyendo un pipeline de CI/CD con Github Actions

Escrito por el .
programacion
Enlace permanente Comentarios

Hay mucha teoría sobre pipelines de CI/CD, en artículos y libros pero no me ha resultado sencillo encontrar un ejemplo de implementación con todas esas buenas prácticas lo suficientemente genérico para que se adapte en gran medida a cualquier organización. En este artículo muestro un ejemplo de pipeline con workflows reutilizables usando Github Actions, Task y semantic-release. También comento algunos aspecto de contexto por el que surge la necesidad y los pasos hasta llegar a esta nueva implementación.

GitHub

A lo largo del tiempo las empresas generan inevitablemente software heredado (o cantidades ingentes de software heredado) u obsoleto que por falta de tiempo para el mantenimiento, conocimiento o personas que ya no están en la empresa.

Para algunas empresas fundamentalmente tecnológicas es un problema ya que su valor y competitividad se asienta en la tecnología, a día de hoy la totalidad de las empresas son en gran medida empresas de tecnología y dependen de ella.

No poseer una buena tecnología con la que ofrecer sus servicios puede significar no generar beneficios y no ser competitiva que mantenido en el tiempo de una forma u otra el fracaso como compañía seguramente después de momentos dolorosos con varios procesos de despidos. También puede significar la pérdida de personas y su talento o ser incapaz de atraerlo, profesionalmente es más difícil que alguien esté interesado en una empresa si no la considera un ámbito atractivo profesionalmente en la que pueda aprender y crecer, para muchas personas trabajar con herramientas actuales es un requisito. Trabajar con código heredado puede tener su atractivo siempre y cuando haya oportunidades y voluntad de modernizarlo.

Y dicho esto una de las oportunidades en las que he podido cambiar en un contexto de mucho código heredado ha sido el pipeline de CI/CD, al menos para los proyectos modernos o en los que los cambios son posibles. Y después de leer el artículo si quieres comentar, ¿como es el el pipeline de CI/CD que usas en el trabajo? ¿que herramientas usas? ¿está completamente automatizado o hay pasos manuales? ¿hacéis teses funcionales una vez desplegado? ¿si haces algo diferente en tu empresa, que podría mejorar en este? Deja un comentario estaré encantado de leerlo y de aprender.

El pipeline de integración continua y despliegue continuo

A día de hoy un pipeline de integración continua es indispensable, su función es integrar los cambios de los desarrolladores y con pruebas unitarias validar que los cambios de código no introducen errores. Hay mucha teoría y libros explicando las importantes ventajas que proporciona esto. Además de descubrir errores, un pipeline de CI permite hacerlo rápido y de forma automatizada, lo que mejora la eficiencia y rapidez en el desarrollo de software.

El siguiente paso en la automatización es el despliegue continuo con el que el despliegue en el entorno de producción queda también automatizado. Automatizar el despliegue permite reducir el tiempo en introducir cambios, de forma más eficiente con menos esfuerzo y más fiable con menos errores. Quizá requiera una aprobación o despliegue en el momento deseado pero mayormente el despliegue está automatizado.

El último paso es la entrega continua con la que los cambios se despliegan en producción si todas las pruebas automatizadas validan el software correctamente, el software se despliega en producción totalmente automatizada sin aprobaciones. Esto requiere de pruebas automatizadas adicionales como teses de aceptación, funcionales, de seguridad, rendimiento.

Con todas las ventajas de la integración continua y el despliegue continuo el desarrollo de software actual se hace empleando estas técnicas de ingeniería de software.

Contexto

Uno de los puntos en el que había mucho legacy en la empresa en la que trabajo era el CI/CD, más que él es los varios que había, bueno ahora hay uno más pero este más moderno que dedicando tiempo tal vez podría reemplazar alguno de los antiguos.

El inicial era un Jenkins y luego algunos proyecto más modernizados pasaron a Concourse con ambos pipelines de integración continua funcionando. Los despliegues con Jenkins requería de varios pasos manuales con mucho margen de mejora en la automatización, toma una o dos horas hacer un despliegue. Esos Jenkins no eran un servicio administrado que había que mantener y dedicar tiempo a que sus instancias de computación funcionasen correctamente, además los pipelines no están bajo el control de los servicios que los hace poco flexibles, impone limitaciones en los nuevos servicio o requiere seguir las convenciones. Su coste era fijo independientemente de si se usaba o no.

Con Concourse las cosas son un poco mejores pero no usa algunas buenas prácticas, los pipelines de despliegue están separados de los proyectos y cada grupo de aplicaciones tiene su propio repositorio de pipeline de CI/CD, que se ha convertido en código heredado difícil de mantener o que requiere una buena cantidad de tiempo para conocerlos que habiendo alternativas es preferible esas alternativas. Es un servicio administrado pero las alternativas son mejores ya no solo porque otras están mejor documentadas.

Primera solución con Github Actions

Hace un tiempo Github añadió como complemento natural denominado Github Actions que en esencia es una herramienta de CI/CD más moderna. Uno de sus atractivos es que es un servicio administrado con lo que no requiere dedicar tiempo ni personas a administrarlo, por otro lado cualquiera con cuenta en Github puede usarlo con lo que muchos profesionales ya tienen conocimiento avanzado de como usarlo, tiene un coste superado un límite de uso pero es un coste por uso y no fijo independientemente de si se usa o no.

En un primer momento el uso que le dábamos era para pasar los teses unitarios con integración continua para cada push a un pull request y en el merge a la rama main. Luego para hacer el despliegue de algunos servicios en GKE. Luego también para enviar notificaciones a Slack en determinados canales para advertir de un nuevo despliegue y diversa información.

Github Actions tiene un marketplace de acciones en el que hay integraciones para la totalidad de herramientas populares y muchas de las menos conocidas, con lo que usar algo es sencillo, rápido y da mucha flexibilidad en caso de surgir una nueva necesidad.

Nuevo contexto

Usar Github Actions para la integración continua con teses unitarios y ser un servicio administrado ya era una mejora pero con una base de cientos de repositorios no es escalable ir replicando en cada repositorio el pipeline de CI/CD. De forma que ahora surge la necesidad de construir un pipeline reusable y suficientemente genérico para que cubra la necesidad como pipeline de los servicios.

Esto significa separar los repositorios del código de los pipeline nuevamente pero es una contrapartida opcional y preferible que copiar y pegar en la multitud de repositorios de código. Quizá con un monorepo sería otra la herramienta a utilizar pero en el contexto actual de multitud de repositorios es la opción viable, por tiempo, garantía de éxito y esfuerzo.

Utilizando Google Cloud el nuevo pipeline ha de soportar varios artefactos de construcción como Dockerfiles, librerías java y desplegar en GCP, Google App Engine (GAE), Google Cloud Functions o publicar las librerías en repositorios de Maven, de tal forma que el pipeline lo permita y suficientemente flexible para que el código no sea demasiado complicado además se ser escalable y permitir en el futuro añadir nuevos lenguajes, artefactos y entornos de ejecución.

Nueva solución con GitHub Actions

Así que toca buscar herramientas e implementar una solución usando Github Actions de tal forma que el nuevo pipeline sea reutilizable, suficientemente flexible para que cubra las diferentes necesidades y que sea suficientemente sencillo para que no sea un problema de mantenimiento.

Además de tener que construir diferentes artefactos, de poder desplegar en diferentes runtimes ha de soportar repositorios en diferentes lenguajes como Javascript, Typescript, Node y Java en diferentes versiones cada uno.

Para soportar esta diversidad de lenguajes y versiones el pipeline ha de independizarse y no acoplarse a cada lenguaje actual y los hipotéticos futuros, por ejemplo el comando de las tareas para los teses unitarios y análisis estático de código son diferentes para un proyecto de Node y otro de Java, GNU Make es una herramienta que proporciona esa abstracción pero finalmente opto por Task que es una equivalente que se define más simple y fácil de usar.

Otra de la necesidades es de realizar versionado semántico y crear tags en el repositorio de git con cada nueva versión, para los proyectos Java estábamos usando el plugin de Gradle Axion pero ahora necesitando soportar varios lenguajes para resolver este problema está semantic-release que además genera la nueva versión en función de los mensajes de commit.

También me ha sido necesario buscar algo de información de buenas prácticas en un pipeline. Que sea sencillo y que sea rápido por ejemplo en el paso de integración continua, en los siguientes artículos y libros hay más información. Para las fases de este pipeline me he basado en Hashicorp Waypoint que define tres build, deploy y release, separando el deploy del release con el cambio de tráfico a la nueva versión y ahí entrarían diferentes estrategias de release con canary o blue-green además de como realizar las actualizaciones de las instancias o el rollout.

Semantic release.

Enlaces.

Libros.

También he necesitado leer algunas secciones de documentación de Github Actions para aprender, por ejemplo para ver como configurar las reglas de protección de las ramas y diferentes opciones de merge.

Implementación de pipeline reutilizable con Github Actions

El pipeline como el anterior consta de dos fases principales la de build y la de deploy. La de build se divide a su vez en una primera base de comprobaciones en la que están incluidos los teses unitarios y el análisis estático de código en un proyecto Java con Gradle, JUnit, PMD, Checkstyle y Spotbugs. Además de si las comprobaciones son correctas generar la release.

Como es una buena práctica que la fase de integración continua sea rápida para obtener feedback lo más rápido posible cada una de esas comprobaciones se lanza en paralelo, esto tiene más coste pero el beneficio de la inmediatez es preferible (y estar esperando también tiene un coste). En los proyectos de Node las tareas de teses unitarios y análisis estático de código serán otros. Como he mencionado para esta abstracción he utilizado Task combinado con el concepto de matrix de Github Actions para lanzarlos en paralelo y permitir configurar el caller workflow cuales son las tareas de check.

Una vez la fase de check es correcta se lanza la fase de construcción del artefacto y de generación de la release con varias tareas específicas para cada artefacto que se activan con condiciones if en función de cómo ha de construirse el artefacto. Ya sea con un Dockerfile o una librería Java, o más que se añadan posteriormente con Buildpacks por ejemplo.

Construido el artefacto hay que generar la release y publicarlo en el repositorio de artefactos, para una imagen de contenedor en un repositorio de Docker y para una librería en un repositorio de Maven. Para etiquetar el repositorio de Git se utiliza semantic-release que en función de los mensajes de commit genera el siguiente identificador de versión en función de la versión anterior, de los mensajes de commit y de si es una rama de release (main) o no (otro branch y trabajando con Github un pull request).

El workflow de la fase de build

Hay varios archivos de configuración importantes, el de las tareas de Task que proporcionará el repositorio que haga uso de los workflows, el de la configuración de semantic-release que tiene una buena cantidad de opciones de configuración y el del propio workflow de build.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
version: '3'

dotenv: ['.env']

tasks:
  build:
    cmds:
      - ./gradlew $GRADLE_OPTIONS build

  build:check:
    deps:
      - task: build:test
      - task: build:pmd
      - task: build:checkstyle
      - task: build:spotbugs

  build:test:
    cmds:
      - ./gradlew $GRADLE_OPTIONS test

  build:pmd:
    cmds:
      - ./gradlew $GRADLE_OPTIONS pmdMain pmdTest

  build:checkstyle:
    cmds:
      - ./gradlew $GRADLE_OPTIONS checkstyleMain checkstyleTest

  build:spotbugs:
    cmds:
      - ./gradlew $GRADLE_OPTIONS spotBugMain spotBugTest

  build:release:
    - npx semantic-release --no-ci --dry-run

  build:publish:
    - echo "publish"

  run:
    cmds:
      - ./gradlew $GRADLE_OPTIONS run

  noop:
    - echo "noop"
taskfile.yml
1
2
3
4
5
6
7
8
#!/usr/bin/env bash

# Parameters
# * $1: ${nextRelease.version}
# * $2: ${lastRelease.version}

echo "$1" > .semantic-release-next-version
echo "$2" > .semantic-release-last-version
miscellaneous/task/publish.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
  "plugins": [
    [
      "@semantic-release/commit-analyzer", {
        "preset": "angular",
        "releaseRules": [{
            "message": "**", "release": "patch"
        }]
      }
    ],
    [
      "@semantic-release/release-notes-generator", {
        "presetConfig": {
          "issuePrefixes": ["ISSUE-"],
          "issueUrlFormat": "https://organization.atlassian.net/browse//{{id}}"
        }
      }
    [
      "@semantic-release/changelog", {
        "changelogFile": "CHANGELOG.md"
      }
    ],
    [
      "@semantic-release/git", {
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }],
    [
      "@semantic-release/exec", {
        "publishCmd": "./miscellaneous/task/publish.sh \"${nextRelease.version}\" \"${lastRelease.version}\""
      }
    ]
  ],
  "branches": [
    { "name": "main" },
    { "name": "*", "prerelease": true }
  ]
}
.releaserc
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
name: build

on:
  workflow_call:
    inputs:
      ref:
        description: Reference to checkout
        required: true
        type: string
        default: 'main'
      java-version:
        description: Java version
        type: string
      node-version:
        description: Node version
        type: string
      setup-gradle:
        description: Setup Gradle
        type: boolean
      check-tasks:
        description: Tasks to execute for code check
        type: string
        required: true
      artifact:
        description: Artifact type
        type: string
        required: true
      artifact-name:
        description: Artifact name
        type: string
        required: true
      artifact-context:
        description: Artifact context
        type: string
        required: true
      repository:
        description: Artifact repository
        type: string
        required: true
    outputs:
      version:
        description: Next release version
        value: ${{ jobs.release.outputs.version }}
      last-version:
        description: Last release version
        value: ${{ jobs.release.outputs.last-version }}
      next-version:
        description: Next release version
        value: ${{ jobs.release.outputs.next-version }}
      sha:
        description: Release sha
        value: ${{ jobs.release.outputs.sha }}
      branch:
        description: Release branch
        value: ${{ jobs.release.outputs.branch }}
      build-number:
        description: Release build number
        value: ${{ jobs.release.outputs.build-number }}
      build-date:
        description: Release build date
        value: ${{ jobs.release.outputs.build-date }}
      image-id:
        description: Release image-id
        value: ${{ jobs.release.outputs.image-id }}
    secrets:
      GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME:
        required: true
      GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD:
        required: true

env:
  GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER: projects/118532964247/locations/global/workloadIdentityPools/runners/providers/github
  GOOGLE_CLOUD_SERVICE_ACCOUNT: gar-mgm-repository@common-1234.iam.gserviceaccount.com
  GOOGLE_ARTIFACT_REGISTRY_HOST: us-east4-docker.pkg.dev
  GOOGLE_ARTIFACT_REGISTRY_PROJECT: common-1234
  GOOGLE_ARTIFACT_REGISTRY_REPOSITORY: repository

jobs:
  check:
    runs-on: ubuntu-22.04
    strategy:
      matrix:
        task: ${{ fromJSON(inputs.check-tasks) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}
          fetch-depth: 0
      - name: Setup Java
        uses: ./.github/actions/setup-java
        if: ${{ inputs.java-version != null }}
        with:
          java-version: ${{ inputs.java-version }}
      - name: Setup Node
        uses: ./.github/actions/setup-node
        if: ${{ inputs.node-version != null }}
        with:
          node-version: ${{ inputs.node-version }}
      - name: Setup Gradle
        uses: ./.github/actions/setup-gradle
        if: ${{ inputs.setup-gradle }}
        with:
          github-packages-repository-download-username: ${{ secrets.GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME }}
          github-packages-repository-download-password: ${{ secrets.GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD }}
      - name: Setup Task
        uses: ./.github/actions/setup-task
      - name: Run task ${{ matrix.task }}
        run: task ${{ matrix.task }}
  release:
    needs: [check]
    runs-on: ubuntu-22.04
    outputs:
      last-version: ${{ steps.build-information.outputs.last-version }}
      next-version: ${{ steps.build-information.outputs.next-version }}
      version: ${{ steps.build-information.outputs.version }}
      sha: ${{ steps.build-information.outputs.sha }}
      branch: ${{ steps.build-information.outputs.branch }}
      build-number: ${{ steps.build-information.outputs.build-number }}
      build-date: ${{ steps.build-information.outputs.build-date }}
      image-id: ${{ steps.docker-build-push.outputs.imageid }}
    permissions:
      contents: 'write'
      id-token: 'write'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}
          fetch-depth: 0
      - name: Setup Semantic release
        uses: ./.github/actions/setup-semantic-release
      - name: Semantic release
        shell: bash
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |-
          unset GITHUB_ACTIONS && npx semantic-release --no-ci          
      - name: Build information
        id: build-information
        shell: bash
        run: |-
          LAST_VERSION="$(cat .semantic-release-last-version)"
          NEXT_VERSION="$(cat .semantic-release-next-version)"
          VERSION="${NEXT_VERSION}"
          SHA="$(git rev-parse --short HEAD)"
          BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
          BUILD_NUMBER="${{ github.run_number }}"
          BUILD_DATE="$(date +"%Y-%m-%dT%H-%M-%S")"
          echo "Last version: ${LAST_VERSION}"
          echo "Next version: ${NEXT_VERSION}"
          echo "Version: ${VERSION}"
          echo "SHA: ${SHA}"
          echo "Branch: ${BRANCH}"
          echo "Build number: ${BUILD_NUMBER}"
          echo "Build date: ${BUILD_DATE}"            
          echo "last-version=${LAST_VERSION}" >> $GITHUB_OUTPUT
          echo "next-version=${NEXT_VERSION}" >> $GITHUB_OUTPUT
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
          echo "sha=${SHA}" >> $GITHUB_OUTPUT
          echo "branch=${BRANCH}" >> $GITHUB_OUTPUT
          echo "build-number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
          echo "build-date=${BUILD_DATE}" >> $GITHUB_OUTPUT          
      - name: Authenticate to Google Cloud
        id: google-cloud-auth
        uses: google-github-actions/auth@v2
        if: ${{ inputs.artifact == 'dockerfile' && inputs.repository == 'google-artifact-registry' }}
        with:
          token_format: access_token
          workload_identity_provider: ${{ env.GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ env.GOOGLE_CLOUD_SERVICE_ACCOUNT }}
      - name: Release library
        if: ${{ inputs.artifact == 'jar-library' && github.ref_name == 'main' && github.event_name == 'push' }}
        shell: bash
        run: |-
          task release          
      - name: Publish library
        if: ${{ inputs.artifact == 'jar-library' && github.ref_name == 'main' && github.event_name == 'push' }}
        run: |-
          task publish          
      - name: Login to Google Artifact Registry
        uses: docker/login-action@v3
        if: ${{ inputs.artifact == 'dockerfile' && inputs.repository == 'google-artifact-registry' }}
        with:
          registry: ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}
          username: oauth2accesstoken
          password: ${{ steps.google-cloud-auth.outputs.access_token }}
      - name: Build and push docker image to Google Artifact Registry
        id: docker-build-push
        uses: docker/build-push-action@v5
        if: ${{ inputs.artifact == 'dockerfile' && inputs.repository == 'google-artifact-registry' }}
        with:
          push: true
          context: ${{ inputs.artifact-context }}
          tags: |
            ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}/${{ inputs.artifact-name }}:latest
            ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}/${{ inputs.artifact-name }}:${{ steps.build-information.outputs.sha }}
            ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}/${{ inputs.artifact-name }}:${{ steps.build-information.outputs.branch }}
            ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}/${{ inputs.artifact-name }}:${{ steps.build-information.outputs.version }}            
          build-args: |
            ARTIFACT_NAME=${{ inputs.artifact-name }}
            GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME=${{ secrets.GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME }}
            GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD=${{ secrets.GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD }}
            VERSION=${{ steps.build-information.outputs.version }}
            SHA=${{ steps.build-information.outputs.sha }}
            BUILD_NUMBER=${{ steps.build-information.outputs.build-number }}
            BUILD_DATE=${{ steps.build-information.outputs.build-date }}            
.github/workflows/build.yml

El workflow de la fase de deploy

La fase de deploy ha de desplegar el artefacto en el entorno de ejecución que necesite el servicio, puede ser un servicio que hace uso de GKE, de GAE o un Google Function. Hay dos entornos el de desarrollo que es un entorno de pruebas y el de producción, con reglas para requerir aprobaciones para hacer el despliegue que se han de configurar en cada repositorio.

Varios de los siguientes steps del workflow tienen if condicionales para que se activen los steps que corresponden. Añadir un nuevo entorno de ejecución sería añadir una nueva clave para identificar el entorno y nuevos steps para hacer su despliegue como corresponde. Tanto el workflow de build como el de deploy reciben una buena cantidad de parámetros con la que configurar el comportamiento de los workflows.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
name: deploy

on:
  workflow_call:
    inputs:
      ref:
        description: Reference to checkout
        type: string
        required: true
        default: 'main'
      dry-run:
        description: Dry run (no deploy)
        type: boolean
        required: true
        default: false
      environment:
        description: Environment to deploy
        type: string
        required: true
        default: 'development'
      runtime:
        description: Runtime type
        type: string
        required: true
      gke-directory:
        description: GKE deployment directory
        type: string
        required: false
      gke-docker-image-name:
        description: Docker image name to deploy
        type: string
        required: false
      gae-deliverables:
        description: Google App Engine deliverables
        type: string
        required: false
      gae-project-id:
        description: Google App Engine project id
        type: string
        required: false
      gae-image-id:
        description: Google App Engine image id
        type: string
        required: false
      version:
        description: Version to deploy
        type: string
        required: true
      sha:
        description: Sha to deploy
        type: string
        required: true
      branch:
        description: Branch to deploy
        type: string
        required: true
      build-number:
        description: Build number
        type: string
        required: true
      build-date:
        description: Build date
        type: string
        required: true
    secrets:
      slack-webhook-url:
        required: true

concurrency:
  group: deploy
  cancel-in-progress: true

env:
  GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER: projects/118532964247/locations/global/workloadIdentityPools/runners/providers/github
  GOOGLE_CLOUD_SERVICE_ACCOUNT: gar-mgm-repository@common-1234.iam.gserviceaccount.com
  GOOGLE_ARTIFACT_REGISTRY_HOST: us-east4-docker.pkg.dev
  GOOGLE_ARTIFACT_REGISTRY_PROJECT: common-1234
  GOOGLE_ARTIFACT_REGISTRY_REPOSITORY: repository
  GKE_PROJECT_DEVELOPMENT: infrastructure-devevelopment-1234
  GKE_PROJECT_PRODUCTION: infrastructure-production-1234

jobs:
  deployment:
    runs-on: ubuntu-22.04-4core
    environment: ${{ inputs.environment }}
    permissions:
      contents: 'read'
      id-token: 'write'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.ref }}
          fetch-depth: 0
      - name: Set deployment variables
        shell: bash
        run: |-
          echo "DEPLOYMENT_DIRECTORY=${{ inputs.gke-directory }}" >> $GITHUB_ENV
          if [ '${{ inputs.environment }}' == 'development' ]; then
            echo "GKE_PROJECT=${{ env.GKE_PROJECT_DEVELOPMENT }}" >> $GITHUB_ENV
          fi
          if [ '${{ inputs.environment }}' == 'production' ]; then
            echo "GKE_PROJECT=${{ env.GKE_PROJECT_PRODUCTION }}" >> $GITHUB_ENV
          fi          
      - name: Authenticate to Google Cloud
        id: google-cloud-auth
        uses: google-github-actions/auth@v2
        if: ${{ !inputs.dry-run && inputs.runtime == 'gke' }}
        with:
          token_format: access_token
          workload_identity_provider: ${{ env.GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ env.GOOGLE_CLOUD_SERVICE_ACCOUNT }}
      - name: Setup GKE credentials
        uses: ./.github/actions/setup-gke-credentials
        if: ${{ !inputs.dry-run && inputs.runtime == 'gke' }}
        with:
          gke-project: ${{ env.GKE_PROJECT }}
      - name: Setup Kustomize
        shell: bash
        if: ${{ !inputs.dry-run && inputs.runtime == 'gke' }}
        run: |-
          cd ${{ env.DEPLOYMENT_DIRECTORY }}
          curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64
          chmod u+x ./kustomize          
      - name: Deploy to GKE
        shell: bash
        if: ${{ !inputs.dry-run && inputs.runtime == 'gke' }}
        env:
          GOOGLE_ARTIFACT_REGISTRY_HOST: ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}
          GOOGLE_ARTIFACT_REGISTRY_PROJECT: ${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}
          GOOGLE_ARTIFACT_REGISTRY_REPOSITORY: ${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}
          DOCKER_IMAGE_NAME: ${{ inputs.gke-docker-image-name }}
        run: |-
          cd ${{ env.DEPLOYMENT_DIRECTORY }}
          ./kustomize edit set image ${GOOGLE_ARTIFACT_REGISTRY_HOST}/${GOOGLE_ARTIFACT_REGISTRY_PROJECT}/${GOOGLE_ARTIFACT_REGISTRY_REPOSITORY}/${DOCKER_IMAGE_NAME}:latest=${GOOGLE_ARTIFACT_REGISTRY_HOST}/${GOOGLE_ARTIFACT_REGISTRY_PROJECT}/${GOOGLE_ARTIFACT_REGISTRY_REPOSITORY}/${DOCKER_IMAGE_NAME}:${{ inputs.version }}
          ./kustomize build . | kubectl apply -f -          
      - name: Deploy to GAE
        uses: google-github-actions/deploy-appengine@v2
        if: ${{ !inputs.dry-run && inputs.runtime == 'gae' }}
        with:
          project_id: ${{ inputs.gae-project-id }}
          deliverables: ${{ inputs.gae-deliverables }}
          image_url: ${{ env.GOOGLE_ARTIFACT_REGISTRY_HOST }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_PROJECT }}/${{ env.GOOGLE_ARTIFACT_REGISTRY_REPOSITORY }}/${{ inputs.gke-docker-image-name }}:${{ inputs.version }}
      - name: Slack notification deploy
        uses: ./.github/actions/slack-notification-deploy
        with:
          ref: ${{ inputs.ref }}
          slack-webhook-url: ${{ secrets.slack-webhook-url }}
          environment: ${{ inputs.environment }}
          runtime: ${{ inputs.runtime }}
          version: ${{ inputs.version }}
          sha: ${{ inputs.sha }}
          branch: ${{ inputs.branch }}
          build-number: ${{ inputs.build-number }}
.github/workflows/deploy.yml

El workflow reutilizable del CI/CD

Estos son los workflows reutilizables del pipeline, cada repositorio tiene que definir un workflow que los invoque y proporcione los argumentos necesarios. Este puede ser uno de ejemplo para un proyecto de Java que construye una imagen de Docker con un Dockerfile, lo publica en Google Artifact Repository y lo despliega en GKE.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
name: main

on:
  push:
    branches:
      - main
      - master
  pull_request:
    types: ['opened', 'synchronize']
  merge_group:
  workflow_dispatch:
    inputs:
      ref:
        description: 'Reference to checkout'
        required: true
        type: string

jobs:
  build:
    name: build
    uses: organization/platform-github-actions-workflows/.github/workflows/build.yml@main
    with:
      ref: ${{ inputs.ref || github.head_ref || github.ref_name }}
      java-version: '21'
      node-version: '20'
      setup-gradle: true
      check-tasks: "['build:test', 'build:pmd', 'build:checkstyle', 'build:spotbugs']"
      artifact: dummy
      artifact-name: platform-github-actions-workflows
      artifact-context: miscellaneous/docker/
      repository: google-artifact-registry
    secrets:
      GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME: ${{ secrets.GH_USER_NAME }}
      GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD: ${{ secrets.GH_PACKAGES_DOWNLOAD_TOKEN }}
  deploy-development:
    name: deploy-development
    needs: [build]
    uses: organization/platform-github-actions-workflows/.github/workflows/deploy.yml@main
    with:
      ref: ${{ inputs.ref || github.head_ref || github.ref_name }}
      dry-run: true
      environment: development
      runtime: gke
      gke-directory: miscellaneous/gke/development
      gke-docker-image-name: platform-github-actions-workflows
      version: ${{ needs.build.outputs.version }}
      sha: ${{ needs.build.outputs.sha }}
      branch: ${{ needs.build.outputs.branch }}
      build-number: ${{ needs.build.outputs.build-number }}
      build-date: ${{ needs.build.outputs.build-date }}
    secrets:
      slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
  deploy-production:
    name: deploy-production
    if: contains('["main", "master"]', github.ref_name) && github.event_name == 'push'
    needs: [build, deploy-development]
    uses: organization/platform-github-actions-workflows/.github/workflows/deploy.yml@main
    with:
      ref: ${{ inputs.ref || github.head_ref || github.ref_name }}
      dry-run: true
      environment: production
      runtime: gke
      gke-directory: miscellaneous/gke/production
      gke-docker-image-name: platform-github-actions-workflows
      version: ${{ needs.build.outputs.version }}
      sha: ${{ needs.build.outputs.sha }}
      branch: ${{ needs.build.outputs.branch }}
      build-number: ${{ needs.build.outputs.build-number }}
      build-date: ${{ needs.build.outputs.build-date }}
    secrets:
      slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}




.github/workflows/main.yml

Para reutilizar acciones comunes en varios jobs el pipeline incluye algunas acciones también reutilizables, para configurar Gradle, Node, Task, Google Cloud y notificaciones de Slack.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
name: setup-gradle
description: 'Setup Gradle'

inputs:
  java-version:
    type: string
    default: '21'
  java-distribution:
    type: string
    default: 'temurin'

runs:
  using: "composite"
  steps:
    - name: Setup JDK ${{ inputs.java-version }}-${{ inputs.java-distribution }}
      uses: actions/setup-java@v4
      with:
        java-version: ${{ inputs.java-version }}
        distribution: ${{ inputs.java-distribution }}
.github/actions/setup-java/action.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: setup-gradle
description: 'Setup Node'

inputs:
  node-version:
    type: string
    default: '20'

runs:
  using: "composite"
  steps:
    - name: Setup Node ${{ inputs.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - name: Install npm packages
      shell: bash
      run: |-
        npm install        
.github/actions/setup-node/action.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: setup-gradle
description: 'Setup Gradle'

inputs:
  github-packages-repository-download-username:
    required: true
  github-packages-repository-download-password:
    required: true

runs:
  using: "composite"
  steps:
    - name: Setup Gradle
      uses: gradle/actions/setup-gradle@v3
    - name: Configure Task
      shell: bash
      run: |-
        echo "GRADLE_OPTIONS=-console plain" >> .env
        echo "GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_USERNAME=${{ inputs.github-packages-repository-download-username }}" >> .env
        echo "GITHUB_PACKAGES_REPOSITORY_DOWNLOAD_PASSWORD=${{ inputs.github-packages-repository-download-password }}" >> .env        
.github/actions/setup-gradle/action.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
name: setup-task
description: 'Setup Task'

inputs:
  task-version:
    default: '3.x'

runs:
  using: "composite"
  steps:
    - name: Setup Task
      uses: arduino/setup-task@v2
      with:
        version: 3.x
.github/actions/setup-task/action.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
name: setup-gradle
description: 'Setup Semantic Release'

inputs:
  node-version:
    type: string
    default: '20'

runs:
  using: "composite"
  steps:
    - name: Setup semantic release
      uses: ./.github/actions/setup-node
      with:
        node-version: ${{ inputs.node-version }}
.github/actions/setup-semantic-release/action.yml
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
name: slack-notification-deploy
description: 'Slack notification deploy'

inputs:
  ref:
    description: Reference to checkout
    type: string
    required: false
  slack-webhook-url:
    description: Reference to slack webhook url
    type: string
    required: true
  environment:
    description: Environment to deploy
    type: string
    required: true
  runtime:
    description: Runtime to deploy
    type: string
    required: true
  version:
    description: Version to deploy
    type: string
    required: true
  sha:
    description: Commit SHA to deploy
    type: string
    required: true
  branch:
    description: Branch to deploy
    type: string
    required: true
  build-number:
    description: Build number
    type: string
    required: true
  build-date:
    description: Build date
    type: string
    required: true

runs:
  using: "composite"
  steps:
    - name: Get information
      id: info
      shell: bash
      run: |-
        REPOSITORY=$(echo "${{ github.repository }}" | sed -e 's/organization\///g')
        if [ -n "${{ github.event.pull_request.number }}" ]; then
          PR="${{ github.event.pull_request.number }}"
        else
          PR=$(echo "${{ github.event.head_commit.message }}" | head -1 | sed -r 's/^Merge pull request \#([0-9]+) from ([a-z]+)\/([A-Z0-9\-]+)$|^(.*)()()$/\1/')
        fi
        echo "repository=${REPOSITORY}" >> $GITHUB_OUTPUT
        echo "pr=${PR}" >> $GITHUB_OUTPUT
        echo "status_emoji=$(case "${{ job.status }}" in success) echo ":large_green_circle:" ;; failure) echo ":red_circle:" ;; *) echo ":large_yellow_circle:" ;; esac)" >> $GITHUB_OUTPUT        
    - name: Send job notification to Slack
      id: slack
      uses: slackapi/slack-github-action@v1.25.0
      env:
        SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }}
        SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
      with:
        payload: |
          {
            "blocks": [
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "${{ steps.info.outputs.status_emoji  }} Deploy for *${{ steps.info.outputs.repository }}* in *${{ inputs.environment }}*"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":github: Repository: <https://github.com/${{ github.repository }}|${{ steps.info.outputs.repository }}>"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":github: Pull request: <https://github.com/${{ github.repository }}/pull/${{ steps.info.outputs.pr }}|${{ steps.info.outputs.pr }}>"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":hammer_and_wrench: Environment: ${{ inputs.environment }}"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":gear: Runtime: ${{ inputs.runtime }}"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":git: Branch: <https://github.com/${{ github.repository }}/tree/${{ inputs.branch || inputs.ref }}|${{ inputs.branch || inputs.ref }}>"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":git: Commit: <https://github.com/${{ github.repository }}/commit/${{ inputs.sha }}|${{ inputs.sha }}>"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":package: Version: ${{ inputs.version }}"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": ":dart: Build: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|${{ github.run_number }}>"
                }
              }
            ]
          }          
.github/actions/slack-notification-deploy/action.yml

Este es el aspecto de la notificación en Slack.

Pipeline de Github Actions Notificación en Slack

Pipeline de Github Actions y Notificación en Slack

Cambios a futuro

Los pipelines probablemente son trajes a medida según la organización cada una es diferente aunque todas comparten muchas necesidades. Este ejemplo tampoco es completo, por ejemplo podría añadirse la parte de pruebas una vez desplegado el artefacto en un entorno, pruebas funcionales, de rendimiento, seguridad, etc.

Es interesante también archivar los informes generados en caso de que un paso de check falle en la integración continua. O crear algún workflow periódico que no es útil ejecutar en cada push a una rama como comprobaciones de seguridad en proyectos Java con Owasp para comprobar las dependencias o la cobertura de los teses unitarios.

Si me es posible a medida que realice estos cambios actualizaré el artículo.


Comparte el artículo: