Использовать ли Reusable Workflows в GitHub Actions?

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/post/682208/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *