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
Свою статью оставляю аналогом для русскоязычного сообщества.
ссылка на оригинал статьи https://habr.com/ru/post/682208/
Добавить комментарий