GitHub Actions Workflows
Pattern for CI/CD workflow composition, authentication, and multi-environment deploy architecture.
This document defines how CI/CD workflows should be composed, authenticated, and structured in Ontopix repositories.
Core Principle
GitHub Actions workflows are thin wrappers around Taskfile tasks.
Logic MUST NOT be defined inside GitHub Actions YAML files (shell scripts, complex conditions, etc.). Instead, workflows should simply invoke the corresponding Taskfile task.
Why This Matters
- Local Reproducibility: If CI fails, a developer should be able to run the exact same command locally (
task test:all) to reproduce the issue. - Vendor Neutrality: Logic in
Taskfileis portable; logic in.github/workflows/*.yamlis locked to GitHub. - Simplicity: YAML is terrible for programming. Keep logic in scripts or tasks.
Workflow Composition
Standard Workflow Structure
A standard workflow should look like this:
name: CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Task
uses: arduino/setup-task@v2
# Setup language/environment (example)
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# Logic is delegated to Taskfile
- name: Run Tests
run: task test:all
- name: Lint
run: task lint:check
Always Use Task
Do not write:
- name: Run Tests
run: |
pip install -r requirements.txt
pytest tests/
Do write:
- name: Run Tests
run: task test:all
(Where test:all handles the installation of dependencies or assumes the environment is ready).
Caching Dependencies
While Task handles execution, GitHub Actions should handle caching to speed up builds.
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
Separation of Concerns
- CI (GitHub Actions): Triggers, environment setup (runner, language version), caching, credential injection.
- Execution (Taskfile): Installation commands, build steps, test execution, linting rules.
Authentication
Workflows MUST use OIDC role assumption for AWS access. Never store long-lived AWS credentials (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) in GitHub Secrets.
Three-Tier Trust Model
Authentication follows the OIDC Trust Tier Model (Infra ADR-003):
| Tier | OIDC Subject Constraint | Purpose | Workflow type |
|---|---|---|---|
| CI (read) | repo:ontopix/*:* | Read-only operations from any ref | ci.yml |
| Deploy (write) | repo:ontopix/*:ref:refs/heads/{master,pre,dev} | Write operations from deploy branches | deploy-*.yml |
| Release (publish) | repo:ontopix/*:ref:refs/tags/* | Publish operations from version tags | release.yml |
OIDC Role Assumption
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/GitHubActions-{Service}-{Access}Role
aws-region: eu-central-1
Key points:
AWS_ACCOUNT_IDis stored as a GitHub Actions variable (not a secret) — it is not sensitive.- Role naming convention:
GitHubActions-{Service}-{Access}Role(e.g.,GitHubActions-Lambda-DeployRole,GitHubActions-ECR-PullRole). - No long-lived credentials: OIDC tokens are short-lived and scoped to the workflow run.
Why Not GitHub Environments?
GitHub Environments provide deployment protection rules (required reviewers, wait timers). We use ref-based OIDC trust instead:
- Organizational discipline over process gates — branch protection rules on
master,pre, anddevprevent unauthorized pushes. Code review and CI are enforced at the branch level, not the workflow level. - Faster deployments — no manual approval button to click in the GitHub UI.
- Simpler policy model — trust is determined at claim time (OIDC token
subfield), not at execution time.
This was explicitly evaluated and deferred in Infra ADR-003. Ref-based subjects remain the sole trust model until org-level environment policies are in place.
Non-AWS Secrets
For non-OIDC secrets (API keys, tokens for third-party services), pass them via env:
- name: Publish package
run: task release:publish
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
Multi-Environment Deploy Architecture
Projects deploying to multiple environments (dev, pre, prod) SHOULD follow the reusable workflow + thin caller pattern.
File Layout
.github/workflows/
├── ci.yml # Reusable — lint, test, build
├── deploy.yml # Reusable — all deploy logic, parameterized by environment
├── deploy-dev.yml # Caller — triggers: push to dev + manual
├── deploy-pre.yml # Caller — triggers: manual only
└── deploy-prod.yml # Caller — triggers: manual only
Reusable Deploy Workflow
The reusable workflow contains all deploy logic and accepts the environment as input:
# deploy.yml
name: deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
description: "Target environment (dev, pre, prod)"
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
# ... setup steps ...
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/GitHubActions-Lambda-DeployRole
aws-region: eu-central-1
- name: Deploy
run: task deploy:all
env:
ENVIRONMENT: ${{ inputs.environment }}
Thin Caller Workflows
Each environment gets a minimal caller (~15 lines) that defines only the trigger:
# deploy-dev.yml
name: Deploy to Dev
on:
push:
branches: [dev]
workflow_dispatch:
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
permissions:
id-token: write
contents: read
with:
environment: dev
secrets: inherit
# deploy-prod.yml
name: Deploy to Prod
on:
workflow_dispatch:
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
permissions:
id-token: write
contents: read
with:
environment: prod
secrets: inherit
Why This Structure?
| Alternative | Problem |
|---|---|
| Single workflow with matrix | Cannot have different triggers per environment |
workflow_dispatch with env dropdown | No branch constraint — anyone can type any value |
| GitHub Environments | Deferred (see Authentication) |
| Copy-paste per environment | Logic duplication — a change in deploy steps requires updating 3 files |
Thin callers make trigger policy auditable at a glance: open deploy-prod.yml, see on: workflow_dispatch — done. The reusable workflow is written once and tested once.
Trigger Strategy
Recommended Defaults
| Environment | Trigger | Rationale |
|---|---|---|
dev | on: push: branches: [dev] + workflow_dispatch | Continuous deployment — every merge deploys |
pre | on: workflow_dispatch | Deliberate promotion — staging gate |
prod | on: workflow_dispatch | Deliberate promotion — requires human intent |
These are recommended defaults. Projects MAY adjust to match their release cadence:
- A project with no staging environment omits
deploy-pre.yml. - A project that auto-deploys to pre on merge to
masteraddson: push: branches: [master]todeploy-pre.yml. - A single-environment project uses one
deploy.ymldirectly (no callers needed).
CI Triggers
# ci.yml (or ci-{project}.yml for monorepos)
on:
pull_request:
branches: [master, dev]
push:
branches: [master, dev]
CI runs on both PRs (validation) and pushes (post-merge verification). Monorepos MAY use path filters to limit CI to changed projects.
Concurrency Control
Deploy workflows MUST include concurrency settings:
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
- Scoped by environment: A dev deploy does not block or cancel a prod deploy.
- No cancellation:
cancel-in-progress: falseensures a running deploy completes. Cancelling a mid-flight deploy can leave resources in an inconsistent state (e.g., Lambda updated but smoke test skipped).
CI workflows MAY use cancel-in-progress: true to save runner minutes on superseded PRs.
Recommended Workflows
Every repository SHOULD have at least:
ci.yml: Runs on Pull Requests and pushes.task lint:checktask test:alltask build(if applicable)
For Applications (Services)
deploy.yml+deploy-{env}.ymlcallers: Deploy to target environments.task deploy:all(or component-specific deploy tasks)- See Multi-Environment Deploy Architecture for structure.
For Libraries (Packages)
release.yml: Runs on creation of version tags (e.g.,v1.0.0).task release:publish- Publishes the artifact to a registry (CodeArtifact, npm, Docker Hub).
Anti-Patterns
- Inline Scripts:
run: | ... 10 lines of bash ...— move to a script file or a Task. - Hidden Logic: Logic that only exists in
.github/workflowsis logic that developers cannot run locally. - Divergent Behavior: CI runs
pytestbut developers runmake test. Usetaskfor both. - Long-lived AWS credentials:
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYin GitHub Secrets. Use OIDC. - GitHub Environments for trust: Using Environment protection rules as the primary deploy gate instead of OIDC ref constraints.
- Workflow-embedded deploy logic: Deploy commands, AWS CLI calls, or build steps written directly in YAML instead of delegated to Taskfile tasks.
Topic Ownership
This pattern owns workflow architecture — how workflows are composed, authenticated, triggered, and structured. Other patterns own what is built and deployed:
| Pattern | Owns |
|---|---|
| GitHub Actions Workflows (this) | Composition, authentication, triggers, concurrency, multi-env architecture |
| Lambda Deploy | Lambda packaging (zip vs S3), Terraform ignore_changes, bundle thresholds |
| ECR + GitHub Actions | Container image build, push, ECR lifecycle and scanning |
| CodeArtifact + GitHub Actions | Private package publishing and consumption |
| Taskfile Contract | Task interface, naming conventions, namespaces |
| Infrastructure Layout | Where IaC code lives, state management |
Service-specific patterns SHOULD reference this pattern for workflow structure rather than embedding their own trigger, OIDC, or environment examples.