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
| Asset | Purpose |
|---|---|
| Composite actions | Reusable job steps: build, test in container, image build and push, Terraform plan and apply, post-deploy verification |
| Terraform modules | Network, cluster, service, logging and observability stacks |
| Workflow templates | Build 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:
| Environment | Reviewers | Wait timer | Deployment branches |
|---|---|---|---|
| dev | None | None | dev |
| stage | 1 reviewer | None | stage |
| production | 2 reviewers | Optional | main |
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
Workflow rules
- Workflow files exist on the default branch —
workflow_runandworkflow_dispatchload from there. - Every job declares the minimum
permissions. - Third-party actions are pinned to a commit SHA, not a floating tag.
- Secrets are never echoed; use
::add-mask::for derived values. - A failing gate is fixed, not skipped with
continue-on-error. - Deployment jobs use
environment:, so approval and audit are automatic. - Shared actions are versioned; a change to a shared action is a reviewed change.