CI система GitHub Actions достаточно свежа по сравнению со своими конкурентами, но продолжает радовать сочетанием легкости использования и постепенным расширением функционала. На мой взгляд, шаблонизация используемых пайплайнов это безумно важная составляющая, и в конце 2021 года GitHub закрыли этот вопрос, представив на наш суд Reusable Workflows. В данной статье я попробую поделиться собственным опытом построения проекта полностью на основе шаблонов workflow и порассуждать о применимости этого подхода.

Вообще, у GitHub Actions присутствуют две возможности шаблонизации: через Composite Actions и через Reusable Workflows. Основное отличие в следующем: composite action сворачивает группу шагов в единую точку входа, которая может переиспользоваться в разных сборках. А reusable workflow - это следующий уровень абстракции, когда мы заворачиваем набор шагов (как обычных, так и композитных) в готовый шаблон, который будет представлять собой отдельный цельный порядок действий. Цепочка получается такой: репозиторий использует шаблон "workflow", а workflow может использовать шаблоны "actions".

С чего начать

Для полного понимания, разберем конкретный минимальный пример на его составляющие. Есть отдельный выделенный центральный репозиторий, где по пути .github/workflows/ лежит шаблон вида:

name: Run Python code scans and code style checks

on:
  workflow_call:
    inputs:
      python_version:
        description: 'Version of Python to run builds on'
        type: string
        required: false
        default: '3.9'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    runs-on: ubuntu-latest

    timeout-minutes: 10

    permissions:
      contents: read
      packages: write

    steps:
    - uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v3
      with:
        python-version: ${{ inputs.python_version }}

    - name: Install dependencies
      run: pip install -r requirements.txt

    - name: Run Linter
      run: | 
        for file in $(find . -type f -name "*.py")
        do
          echo "Linting $file ..."
          echo "------------------------------------"
          pylint $file
        done

Видим несколько элементов, которые важны для этого описания как для шаблона типа job. Самое первое, что нужно задать - ключевой триггер, который определит этот workflow как reusable и отличит его от обычного.

on:
  workflow_call:

Далее мы имеем возможность определить входные параметры и использовать их в шагах сборки. Это естественным образом дает возможность переиспользовать конкретный шаблон в разных конфигурациях, поддерживать его обратную совместимость для старых и новых репозиториев, сделать шаблон более универсальным.

    inputs:
      python_version:
        description: 'Version of Python to run builds on'
        type: string
        required: false
        default: '3.9'

Обращение к параметру дальше происходит в формате ${{ inputs.<parameter> }}, мы это видим в шаге:

    - name: Set up Python
      uses: actions/setup-python@v3
      with:
        python-version: ${{ inputs.python_version }}

Дальше в секции jobs: мы уже декларируем стандартные настройки задачи со всеми включенными шагами.

Теперь на стороне вызывающего workflow на репозитории с кодом продукта, нам достаточно прописать координаты нашего шаблона на центральном репозитории через директиву uses::

on:
  pull_request:
    branches:
      - develop

jobs:
  check:
    if: github.event_name == 'pull_request'
    uses: artazar/github-workflows/.github/workflows/build_python_check.yml@main

Этого достаточно для самого базового примера, чтобы понять механику использования reusable workflows.

Поясню еще один блок - он в целом необязателен, но полезен для определения настроек по умолчанию для отдельно выделенной сборки и позволяет экономить минуты потребления CI воркеров:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Здесь мы объявляем, что для группы задач, которые идентифицируются по связке workflow + ветка репозитория, мы будем отменять сборку, которая еще в процессе, если в ветку пришли изменения кода.

Более сложный пример

Теперь я хотел бы показать и разобрать полноценный пример настроенного репозитория для приложения со всеми стадиями сборки:

name: Java Maven Build & Deploy

on:
  push:
    branches:
      - develop
      - main
      - stage
    paths-ignore:
      - '.github/**'
  pull_request:
    branches:
      - develop
    paths-ignore:
      - '.github/**'
  workflow_dispatch:

jobs:
  check:
    if: github.event_name == 'pull_request'
    uses: artazar/github-workflows/.github/workflows/build_maven_check.yml@main
    secrets: inherit

  integration:
    if: github.event_name == 'pull_request'
    uses: artazar/github-workflows/.github/workflows/build_maven_test_integration.yml@main
    secrets: inherit

  build:
    if: github.event_name != 'pull_request'
    uses: artazar/github-workflows/.github/workflows/build_maven_publish.yml@main
    with:
      tomcat_image: ghcr.io/artazar/utils/tomcat:main
    secrets: inherit

  vars:
    if:
      contains('
        refs/heads/develop
        refs/heads/main
        refs/heads/stage
        ', github.ref)
    runs-on: ubuntu-latest
    outputs:
      namespace: ${{ steps.ns.outputs.namespace }}
    steps:
    - name: Map branch to namespace
      id: ns
      run: |   
        if [ "${GITHUB_REF}" = 'refs/heads/main' ]
        then
          NAMESPACE="myapp-prod"
          CLUSTER="k8s-001"
        elif [ "${GITHUB_REF}" = 'refs/heads/develop' ]
        then
          NAMESPACE="myapp-dev"
          CLUSTER="k8s-001"
        elif [ "${GITHUB_REF}" = 'refs/heads/stage' ]
        then 
          NAMESPACE="myapp-stg"
          CLUSTER="k8s-001"
        fi

        echo ::set-output name=cluster::${CLUSTER}
        echo "Cluster is set to ${CLUSTER}"

        echo ::set-output name=namespace::${NAMESPACE}
        echo "Namespace is set to ${NAMESPACE}"

  deploy:
    if:
      contains('
        refs/heads/develop
        refs/heads/main
        refs/heads/stage
        ', github.ref)
    uses: artazar/github-workflows/.github/workflows/deploy_kubernetes_flux.yml@main
    needs: [build, vars]
    with:
      app_name: ${{ github.event.repository.name }}
      app_version: ${{ needs.build.outputs.app_version }}
      namespace: ${{ needs.vars.outputs.namespace }}
      cluster: ${{ needs.vars.outputs.cluster }}
      flux_repo: k8s-flux
    secrets: inherit

Выделю здесь следующие моменты:

secrets: inherit

Очень полезное свойство, появившиеся недавно: возможность передавать секреты напрямую из вызывающего репозитория в репозиторий шаблонов. До введения этой возможности, приходилось все секреты прописывать как входные параметры, что неудобно, т.к. в большинстве случаев мы просто хотим безусловно использовать то, что есть в настройках репозитория. Наиболее удобно использовать общие секреты GitHub организации, которые доступны в каждом репозитории, и соответственно передаются как есть в каждый шаблон workflow.

Далее у нас разбивка по задачам:

jobs:
  check:
    uses: artazar/github-workflows/.github/workflows/build_maven_check.yml@main

  integration:
    uses: artazar/github-workflows/.github/workflows/build_maven_test_integration.yml@main

  build:
    uses: artazar/github-workflows/.github/workflows/build_maven_publish.yml@main

  vars:
    ...

  deploy:
    uses: artazar/github-workflows/.github/workflows/deploy_kubernetes_flux.yml@main
    needs: [build, vars]

Здесь видим скелет полноценной сборки, в которую заложены шаблоны для проверок PR, сборки кода и разворачивания приложения. Этот скелет вполне может являться одинаковым для всех типичных репозиториев приложений, что в особенности важно и удобно в случае микросервисов. При таком подходе, когда нужно внести изменение в процесс сборки, мы делаем это в репозитории шаблонов в одной точке, и все вызывающие этот шаблон репозитории применяют это изменение моментально. Впрочем, мы также можем протестировать изменения на отдельно взятом репозитории, т.к. можем для него одного сначала указать ветку шаблона после символа "@":

  build:
    uses: artazar/github-workflows/.github/workflows/build_maven_publish.yml@feature/optimize_build

А когда изменения отлажены и добавлены в основную ветку шаблонов, переключиться обратно.

Также хочу подсветить удобство передачи output values между разными jobs, которое сохраняется и при использовании шаблонов, обратите внимание на передаваемые параметры:

  deploy:
    uses: artazar/github-workflows/.github/workflows/deploy_kubernetes_flux.yml@main
    needs: [build, vars]
    with:
      app_name: ${{ github.event.repository.name }}
      app_version: ${{ needs.build.outputs.app_version }}
      namespace: ${{ needs.vars.outputs.namespace }}
      cluster: ${{ needs.vars.outputs.cluster }}
      flux_repo: k8s-flux
    secrets: inherit

В задачу "deploy" мы получаем версию приложения из задачи-шаблона "build" и координаты для разворачивания приложения из вспомогательной задачи "vars", обе задачи указаны в зависимостях через needs:.

К слову, для того, чтобы yaml файл, лежащий на каждом репозитории, стал максимально универсальным, в моем случае имя приложения берется из названия репозитория:

app_name: ${{ github.event.repository.name }}

А генерация версии - это отдельный шаг в шаблоне сборки, что тоже отвязывает ее от репозитория как такового.

Вложенность

Что с уровнем вложенности? Не исключаю, что может возникнуть желание создать набор атомарных шаблонов и включить их все в один большой "шаблон шаблонов". GitHub Actions на данный момент не позволяет этого сделать, удерживая уровень вложенности как 2 - то есть только связка вызывающего и вызываемого workflow. Здесь уже под каждый отдельный случай нужно думать и разрабатывать свой подход.

На мой вкус, шаблон workflow удобнее всего, когда это выделенная задача - job в терминах GitHub (как и видим на приведеном примере), при этом внутри одного job можно ссылаться на аккуратно обернутые composite actions, делая структуру более и более универсальной. Покажу на примере:

# This workflow will build a package using Maven and then publish it to GitHub packages when a release is created
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#apache-maven-with-a-settings-path

name: Build package with Maven and publish as container image to GitHub Packages

on:
  workflow_call:
    inputs:
      ...

jobs:
  build:
    ...

    steps:
    - uses: actions/checkout@v3
      with:
        ref: ${{ github.head_ref }}   # requirement of GitVersion
        fetch-depth: 0   # requirement of GitVersion

    ### Build actions

    - name: Set up Node.JS
      uses: actions/setup-node@v3
      with:
        node-version: ${{ inputs.node_version }}
        cache: ${{ inputs.node_package_manager }}

    - name: Restore dependencies
      run: yarn install --immutable --immutable-cache --check-cache

    - name: Build the application
      run: yarn build

    ### Publish actions

    - name: Build and push container image
      if: github.event_name != 'pull_request'
      uses: artazar/github-workflows/.github/actions/docker-build-and-push@main
      with:
        container_tags: ${{ steps.version.outputs.image_tag }}
        ghcr_user: ${{ secrets.GHCR_USER }}
        ghcr_token: ${{ secrets.GHCR_TOKEN }}

    - name: Send Slack notification for build failure
      if: failure() && env.SLACK_WEBHOOK_URL != ''
      uses: artazar/github-workflows/.github/actions/slack-notify@main
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Данный шаблон сочетает в себе простые действия вида скриптов run:, готовые действия с общего хранилища как actions/setup-node@v3 и actions/checkout@v3, а также описанные composite actions на том же репозитории шаблонов artazar/github-workflows/.github/actions/docker-build-and-push@main и artazar/github-workflows/.github/actions/slack-notify@main. В такой структуре принцип "сухости" кода (DRY) сводится к схеме:

- repo1 \
- repo2 - tmpl1 - action1
- repo3 - tmpl2 - action2
- ...   - ...   - action3
- ...   - tmplM /
- repoN /

То есть:

  • На уровне описания workflow репозитория мы меняем только те настройки, которые относятся только к этому репозиторию (триггеры, ветки, набор вызываемых шаблонов, уникальные параметры, секреты).

  • На уровне шаблона джобов мы меняем набор входящих шагов, параметры вызова и некоторые настройки по умолчанию.

  • На уровне композитных шагов мы можем вносить изменения в те общие действия, которые могут быть переиспользованы во многих шаблонах.

Такой порядок превращает сборки в интуитивно понятную структуру, которую проще поддерживать.

Ограничения

Есть пара важных моментов, которые сейчас ограничивают использования reusable workflows:

  1. Центральный репозиторий, который хранит шаблоны, должен быть публичным. В целом, это не сильно большое ограничение, но нужно постоянно следить за тем содержимым, что попадает в шаблоны и случайно не пронести важные внутренние данные (наименования, секреты, и прочее). Особенно это актуально, когда происходит отладка шаблонов в ветке и есть желание быстро оттестировать кусок кода. Хранение шаблонов на скрытых репозиториях доступно только на Enterprise лицензии.

  2. Удобство проброса секретов с уровня организации на уровень репозитория тоже доступно только с лицензией, начиная с уровня Team. На полностью бесплатном уровне придется секреты прописывать в каждый репозиторий.

  3. На уровне репозитория с шаблонами нельзя выстроить древовидную иерархию, все yaml файлы сейчас должны лежать строго внутри папки .github/workflows относительно корня. Если количество шаблонов разрастется, это может превратить репозиторий в yaml свалку. Пока приходится придерживаться какой-то иерархии через именование шаблонов.

Выводы

Я считаю функционал Reusable Workflows безусловно важным и нужным в экосистеме GitHub Actions. Он однозначно упрощает поддержку CI кода для множества типичных репозиториев, делая структуру модульной и предотвращая необходимость переносить изменения сквозь множество схожих описания сборок.

В прямом сравнении GitHub Actions конечно все еще проигрывают в своей гибкости более сложным CI системам, таким как Jenkins / Azure Devops / CircleCI, но для старта небольшого проекта с нуля по трудозатратам GitHub Actions выглядит выгоднее и экономичнее.

---

Примеры, приведенные в данной статье, можно найти по ссылке.

Благодарю за внимание и буду рад любым вопросам в комментариях!

P.S.

В процессе написания данной статьи также появился англоязычный лонгрид с подобным разбором, оставляю ссылку на него тоже для полноты покрытия темы:

A Deep Dive into GitHub Actions’ Reusable Workflows

Свою статью оставляю аналогом для русскоязычного сообщества.

Комментарии (1)


  1. kefiiir
    16.08.2022 12:47
    +1

    >Центральный репозиторий, который хранит шаблоны, должен быть публичным.

    И это убивает всю концепцию на корню. В очень многих компаниях публичным не может быть ничего. А жаль, этого прям очень не хватает и приходится делать свои странные велосипеды имитирующие shared libs из дженкинса. Жду кода добавят хотя бы возможность сделать чекаут репы с шаблонами и потом их нормально использовать.