Organizational

GitHub Actions Workflows

Pattern for CI/CD workflow composition, authentication, and multi-environment deploy architecture.

Production

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

  1. Local Reproducibility: If CI fails, a developer should be able to run the exact same command locally (task test:all) to reproduce the issue.
  2. Vendor Neutrality: Logic in Taskfile is portable; logic in .github/workflows/*.yaml is locked to GitHub.
  3. 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):

TierOIDC Subject ConstraintPurposeWorkflow type
CI (read)repo:ontopix/*:*Read-only operations from any refci.yml
Deploy (write)repo:ontopix/*:ref:refs/heads/{master,pre,dev}Write operations from deploy branchesdeploy-*.yml
Release (publish)repo:ontopix/*:ref:refs/tags/*Publish operations from version tagsrelease.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_ID is 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, and dev prevent 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 sub field), 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?

AlternativeProblem
Single workflow with matrixCannot have different triggers per environment
workflow_dispatch with env dropdownNo branch constraint — anyone can type any value
GitHub EnvironmentsDeferred (see Authentication)
Copy-paste per environmentLogic 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

EnvironmentTriggerRationale
devon: push: branches: [dev] + workflow_dispatchContinuous deployment — every merge deploys
preon: workflow_dispatchDeliberate promotion — staging gate
prodon: workflow_dispatchDeliberate 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 master adds on: push: branches: [master] to deploy-pre.yml.
  • A single-environment project uses one deploy.yml directly (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: false ensures 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.


Every repository SHOULD have at least:

  1. ci.yml: Runs on Pull Requests and pushes.
    • task lint:check
    • task test:all
    • task build (if applicable)

For Applications (Services)

  1. deploy.yml + deploy-{env}.yml callers: Deploy to target environments.

For Libraries (Packages)

  1. 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/workflows is logic that developers cannot run locally.
  • Divergent Behavior: CI runs pytest but developers run make test. Use task for both.
  • Long-lived AWS credentials: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in 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:

PatternOwns
GitHub Actions Workflows (this)Composition, authentication, triggers, concurrency, multi-env architecture
Lambda DeployLambda packaging (zip vs S3), Terraform ignore_changes, bundle thresholds
ECR + GitHub ActionsContainer image build, push, ECR lifecycle and scanning
CodeArtifact + GitHub ActionsPrivate package publishing and consumption
Taskfile ContractTask interface, naming conventions, namespaces
Infrastructure LayoutWhere 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.


References