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:
Центральный репозиторий, который хранит шаблоны, должен быть публичным. В целом, это не сильно большое ограничение, но нужно постоянно следить за тем содержимым, что попадает в шаблоны и случайно не пронести важные внутренние данные (наименования, секреты, и прочее). Особенно это актуально, когда происходит отладка шаблонов в ветке и есть желание быстро оттестировать кусок кода. Хранение шаблонов на скрытых репозиториях доступно только на Enterprise лицензии.
Удобство проброса секретов с уровня организации на уровень репозитория тоже доступно только с лицензией, начиная с уровня Team. На полностью бесплатном уровне придется секреты прописывать в каждый репозиторий.
На уровне репозитория с шаблонами нельзя выстроить древовидную иерархию, все 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
Свою статью оставляю аналогом для русскоязычного сообщества.
kefiiir
>Центральный репозиторий, который хранит шаблоны, должен быть публичным.
И это убивает всю концепцию на корню. В очень многих компаниях публичным не может быть ничего. А жаль, этого прям очень не хватает и приходится делать свои странные велосипеды имитирующие shared libs из дженкинса. Жду кода добавят хотя бы возможность сделать чекаут репы с шаблонами и потом их нормально использовать.