Skip to main content

GitHub Actions

A pipeline that is copied between repositories drifts. Qbits uses shared composite actions and Terraform modules, so a new project adopts a known-good pipeline by configuration rather than by transcription.

Repository layout​

.github/
└── workflows/
├── build.yml
├── deploy-dev.yml
├── deploy-stage.yml
└── deploy-prod.yml
docker/
├── Dockerfile
└── build.sh
terraform/
└── <region>/<env>/
├── config.s3.tfbackend
└── terraform.tfvars

Shared building blocks​

AssetPurpose
Composite actionsReusable job steps: build, test in container, image build and push, Terraform plan and apply, post-deploy verification
Terraform modulesNetwork, cluster, service, logging and observability stacks
Workflow templatesBuild and deploy workflows per application type

Version pinning is explicit. A workflow references a tag, not a branch:

env:
sharedTemplatesRef: refs/tags/v1.9

Change that reference deliberately, in a pull request, with the pipeline diff visible.

Build workflow​

name: build

on:
push:
branches: [dev, stage, main]
pull_request:
branches: [dev, stage, main]
workflow_dispatch:

permissions:
id-token: write
contents: read

env:
AWS_REGION: us-east-1
ECR_REPOSITORY: example-service
sharedTemplatesRef: refs/tags/v1.9

jobs:
quality:
uses: <org>/platform-actions/.github/workflows/quality.yml@v1.9
with:
coverage-threshold: '80'

build:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_BUILD_ROLE }}
aws-region: ${{ env.AWS_REGION }}

- name: Build and test in container
uses: <org>/platform-actions/build-test@v1.9

- name: Build and push image
uses: <org>/platform-actions/image-push@v1.9
with:
repository: ${{ env.ECR_REPOSITORY }}
tag: ${{ github.sha }}

Deploy workflow​

name: deploy-prod

on:
workflow_run:
workflows: [build]
types: [completed]
branches: [main]
workflow_dispatch:

permissions:
id-token: write
contents: read

concurrency:
group: deploy-prod
cancel-in-progress: false

jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4

- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_PROD }}
aws-region: us-east-1

- name: Terraform plan
uses: <org>/platform-actions/terraform-plan@v1.9
with:
working-directory: terraform/us-east-1/prod

- name: Terraform apply
uses: <org>/platform-actions/terraform-apply@v1.9
with:
working-directory: terraform/us-east-1/prod

- name: Verify deployment
uses: <org>/platform-actions/post-deploy@v1.9
with:
service: example-service
environment: prod

Environment protection​

Each deployment environment is a GitHub Environment with its own protection rules:

EnvironmentReviewersWait timerDeployment branches
devNoneNonedev
stage1 reviewerNonestage
production2 reviewersOptionalmain

The approval is recorded in GitHub and linked from the release.

Concurrency​

concurrency prevents two deployments to the same environment overlapping. The group is per environment; cancel-in-progress: false ensures a deployment is not half-cancelled.

OIDC to AWS​

No long-lived AWS keys are stored in GitHub. The workflow assumes a role through OIDC, and the role's trust policy restricts which repository and branch can assume it.

data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}

resource "aws_iam_role" "deploy" {
name = "github-deploy-${var.environment}"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = data.aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" =
"repo:${var.org}/${var.repo}:environment:${var.environment}"
}
}
}]
})
}

Prerequisites for a new project​

GitHub
Repository created from the organisation template; environments configured; OIDC variables set; branch protection applied.
AWS
OIDC provider, deploy roles per environment, ECR repository, Terraform state access.
Terraform
Backend configuration per environment, variable files, and the module references for the service type.
Application
Dockerfile, build script, test command, and health endpoint.

Workflow rules​

  1. Workflow files exist on the default branch — workflow_run and workflow_dispatch load from there.
  2. Every job declares the minimum permissions.
  3. Third-party actions are pinned to a commit SHA, not a floating tag.
  4. Secrets are never echoed; use ::add-mask:: for derived values.
  5. A failing gate is fixed, not skipped with continue-on-error.
  6. Deployment jobs use environment:, so approval and audit are automatic.
  7. Shared actions are versioned; a change to a shared action is a reviewed change.