engineering-handbook/cicd.md
Adam Moussa 065a4e9f0e
docs(cicd): correct the cd-sam caller example to match the real contract
The SAM deploy example passed `cfn-role-arn` as a secret and omitted
`deploy-role-arn` entirely. Both are wrong against cd-sam.yaml, which
declares `cfn-role-arn` as a required string INPUT and `deploy-role-arn`
as a required SECRET. A repo scaffolded from the example failed twice:
an unexpected secret, plus a missing required input and secret.

The two ARNs are distinct roles that the old example effectively
conflated into one, so document them side by side: cfn-role-arn is the
CloudFormation execution role the stack deploys as, deploy-role-arn is
the OIDC role the workflow assumes. Also record which inputs have
defaults so callers pass only what they must.

The account ID stays a `<account-id>` placeholder, per the same rule
that removed the hardcoded management-account ARN from the templates.
2026-07-28 12:16:37 -04:00

6.6 KiB

CI/CD Pipelines

Requirement

Every deployable repo must have a CI/CD pipeline. No manual deploys to production. If it deploys to AWS, it needs a pipeline.

Platform

GitHub Actions is the standard CI/CD platform. All pipelines use reusable workflows from the Sea-Haven-Industries/.github org repo (.github/workflows/).

Workflow Structure

Every repo gets two thin workflow files in .github/workflows/:

File Trigger Purpose
ci.yaml pull_request on main Lint, typecheck, test, synth/validate
deploy.yaml push on main Deploy to AWS

CDK Stacks (TypeScript)

# .github/workflows/ci.yaml
name: CI
on:
  pull_request:
    branches: [main]
jobs:
  ci:
    uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@<full-commit-sha>  # main
    with:
      node-version: "24"

# .github/workflows/deploy.yaml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@<full-commit-sha>  # main
    with:
      node-version: "24"
    secrets:
      deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}

SAM Stacks (Python)

# .github/workflows/ci.yaml
name: CI
on:
  pull_request:
    branches: [main]
jobs:
  ci:
    uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha>  # main

# .github/workflows/deploy.yaml
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@<full-commit-sha>  # main
    with:
      stack-name: "your-stack-name"
      cfn-role-arn: "arn:aws:iam::<account-id>:role/github-cfn-execution-role"
    secrets:
      deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}

cd-sam.yaml takes two required inputs and one required secret. The two role ARNs are not interchangeable; they are different roles with different jobs:

Name Kind Purpose
stack-name input (with:) The CloudFormation stack name
cfn-role-arn input (with:) The CloudFormation execution role the stack is deployed as. This is github-cfn-execution-role in the repo's target account. Substitute that account's ID for <account-id>; the account must have the deploy substrate provisioned before the first deploy. Do not point new repos at the management account.
deploy-role-arn secret (secrets:) The OIDC role the workflow assumes, from the repo's AWS_DEPLOY_ROLE_ARN secret (see Authentication)

Passing cfn-role-arn under secrets: fails: it is an input, so the run errors on an unexpected secret and a missing required input.

The remaining inputs have defaults and are only needed when a repo differs from them: region (us-east-1), sam-template (template.yaml), and python-version (3.12). The optional parameter-overrides secret passes Key=Value pairs through to sam deploy.

Authentication

Deploy workflows authenticate to AWS via OIDC (no long-lived credentials). Each repo needs:

  1. An IAM role named githubdeploy-<repo-name> with:
    • OIDC trust policy for token.actions.githubusercontent.com
    • Subject condition: repo:Sea-Haven-Industries/<repo>:ref:refs/heads/main
    • Inline policy allowing sts:AssumeRole on CDK/SAM bootstrap roles
  2. A repo secret AWS_DEPLOY_ROLE_ARN containing the role ARN

Node.js Version

Always pass node-version: "24" to reusable workflows. Local dev uses Node 24 / npm 11 which generates lockfileVersion 3. The workflow defaults match this, but be explicit to avoid drift.

Naming

  • All workflow files: kebab-case
  • Reusable workflow references: pinned to a full 40-character commit SHA with a # main comment — never a branch or tag ref

Workflow Ref Pinning

Reusable workflow references are pinned to a full commit SHA of the central .github repo, with a trailing # main comment:

uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@<full-commit-sha>  # main

Branch refs are mutable: a compromised or bad commit on the central repo would flow instantly into every consumer's CI and deploy path. A SHA pin turns that same change into a reviewable Dependabot PR instead. The comment tells Dependabot (and readers) which ref the pin tracks.

Per the pinning principle, pins are for reproducibility, not for freezing time. Two prerequisites keep them moving:

  1. Every repo's dependabot.yml must include the github-actions ecosystem (weekly), so pin-advance PRs are opened automatically.
  2. Dependabot must be granted access to the internal .github repo at the org level (Org Settings → Advanced Security → Global settings → "Grant Dependabot access to repositories"). Without the grant, update jobs fail with git_dependencies_not_reachable and pins freeze silently — consumers stop receiving central workflow fixes with no visible signal beyond the failed Dependabot run.

When adding a caller workflow by hand, pin to the current tip of the central repo's main (gh api /repos/Sea-Haven-Industries/.github/commits/main --jq .sha) and let Dependabot advance it from there.

When to Add a Pipeline

  • When creating a new deployable project — the pipeline is part of the initial setup, not a follow-up
  • When working on an existing project that lacks one — flag it and add it as part of the current work

A project is not production-ready without CI/CD.

PR Auto-Labeling

Pull requests are auto-labeled org-wide by a reusable workflow in .github. The label rules live once, centrally, inside the reusable workflow itself (written to the runner at execution time), so each repo needs only a short caller and no per-repo labeler.yml:

# .github/workflows/labeler.yml — the per-repo caller
name: Labeler
on:
  pull_request:
    branches: [main]
permissions:
  contents: read
  pull-requests: write
  issues: write
jobs:
  label:
    uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@<full-commit-sha>  # main
  • The trigger is plain pull_request, not pull_request_target: private repos take no fork PRs, so the lower-privilege event is sufficient and avoids the pwn-request surface. Because pull_request runs the workflow from the merge commit, the Labeler check appears on the PR that first adds the caller — an absent or failed check means a missing permission, not expected behaviour.
  • The caller MUST grant all three permissions. Reusable-workflow permissions can only be downgraded from the caller, so omitting issues: write (needed to create labels that don't exist yet) or any other grant causes a silent startup_failure.
  • Adding the caller is part of new-repo provisioning.