When I took over delivery for an estate with five application teams, production was reachable four different ways. One team still had a laptop AWS key in a password manager. Another had a Jenkins job nobody would edit, because the last person who tried spent a Friday undoing it. A third had copied a GitHub Actions workflow, then forked it until a pull request on the original could not be reviewed. Staging and production shared one Actions secret store. When something went wrong, rollback was whoever remembered the last image tag in Slack.

Nobody owned the path to production. Each workflow had been written for a ticket, and none of them for whoever gets paged when a deploy hangs. Once one team owns a reusable workflow that every service calls, callers stay thin, Environments own the OIDC subject, and teams stop inventing a private path to production.

This is the first of three notes on one delivery path, followed by database migrations and browser smoke tests. It leaves out SLSA attestations, self-hosted runners, merge queues, Helm or Kubernetes layout, and Windows runners.

Standardisation means one owned template

A YAML file copied into five application repos diverges on the first incident. An unversioned snippet in a wiki fares no better, because nobody reviews a wiki before a production deploy. A versioned workflow_call template using reusable workflows in org/platform-workflows, with CODEOWNERS on the templates, is the only shape a reviewer can hold. The copy and the snippet are how the laptop key came back wearing YAML.

Five teams, four copies of one workflow

The delivery estate this note describes:

  • five application teams shipping into the same staging and production accounts
  • four of those teams already on GitHub Actions, each with a private copy of something that used to be shared
  • one leftover batch job still on Jenkins
  • an org repo, org/platform-workflows, that did not exist when I arrived

It is tempting to treat that as a tooling choice, or to bless a second CI product because one team likes GitLab. The standard is four workload templates (web, static, worker, IaC), GitHub Actions as the only CI product, and a frozen Jenkins.

Two-column diagram: per-team YAML copies, a laptop key, a Jenkins job, and shared repo secrets on the left, versus org reusable workflows, thin callers, and separate staging and production Environments with their own OIDC roles on the right
Figure 1. Copied YAML has no owner. Reusable workflows plus [OpenID Connect](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect) put an owner on the path to production.

Drift the shared pipeline has to prevent

  • Forked workflow, no CODEOWNERS. Someone copies deploy.yml into the service repo. Six months later the original author cannot review a change, because the original no longer exists as a thing anyone opens.
  • AWS_ACCESS_KEY_ID in repo secrets. Long-lived keys in Actions are a security finding. A PAT used to push images is the same class of finding.
  • :latest and a rebuild on the prod job. Production checks out main and builds again. What ran in staging is already gone. Rollback is a guess.
  • One Actions secret store for staging and prod. If the staging workflow can read the production credentials, the Environment names control nothing.
  • Required checks that exist only in a wiki. A check that lives in a wiki gets skipped. The gate has to live on a reusable test workflow the pull request calls.
  • A second CI product for one team. GitLab or Circle "just for this service" splits ownership again. New work goes on Actions, and nothing joins the Jenkins job.
  • uses: ...@main. The caller tracks a moving branch. A template change then reaches production with no pin and no review in the app repo.
  • Pinning every caller of your template to a commit SHA by default. Callers stop receiving security and patch fixes on that major. A SHA pin is justified only when the current v3 line has a regression you cannot take, or new commits on that major are features you have explicitly declined. Write the reason in a comment on the caller, and take the pin off once the major is safe again.
  • permissions: write-all, or relying on a permissive org default. New organisations default GITHUB_TOKEN to read for contents and packages. The failure is an explicit write-all, or an org setting that still grants write by default. Set a permissions: block. A GHCR push job needs packages: write on that job and on the caller. The callee cannot exceed the caller.
  • secrets: inherit into a reusable workflow that did not need them. Inherit dumps the caller's entire secret map into the called workflow. Name the secrets the template declared, or pass none.

Reference repository structure for platform workflows

The platform repo holds the templates. The application repo holds thin callers.

org/platform-workflows/
  .github/
    CODEOWNERS
    workflows/
      test.yml
      web.yml
      static.yml
      worker.yml
      iac.yml
org/payment-service/
  .github/
    workflows/
      ci.yml
      staging.yml
      promote.yml

Polyrepo or monorepo, the caller stays thin. Path filters belong to the team; the templates belong to platform, through CODEOWNERS on /.github/workflows/ @org/platform. test.yml carries the required checks.

The reusable workflow

The template is a workflow_call. Secrets are named, never secrets: inherit. Staging and production are different GitHub Environments, which means different OIDC subjects. On an Environment job the classic shape is repo:org/payment-service:environment:staging versus repo:org/payment-service:environment:production, and therefore different deploy roles.

On repositories created since mid-July 2026, the subject starts with an immutable prefix instead, with the same Environment suffix. CI/CD roles with OIDC shows both formats and how to read the token from a run. Do not guess.

DEPLOY_ROLE_ARN is an Environment variable, not a repository variable. A repo-level vars value would give both Environments the same role. The JWT also carries job_workflow_ref for the called workflow. If you want only org/platform-workflows/.github/workflows/web.yml to assume the role, lock that claim. The IAM side lives in CI/CD roles with OIDC. Do not paste those roles into this repo's callers.

# org/platform-workflows/.github/workflows/web.yml (illustrative)
on:
  workflow_call:
    inputs:
      image:
        required: true
        type: string
      digest:
        required: false
        type: string
      environment:
        required: true
        type: string

permissions:
  contents: read
  id-token: write

concurrency:
  group: ${{ inputs.image }}-${{ inputs.environment }}
  cancel-in-progress: false

jobs:
  build:
    if: ${{ inputs.digest == '' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v7
      - id: push
        run: ./build-and-push --image "${{ inputs.image }}"
  deploy:
    needs: build
    if: ${{ !cancelled() && (needs.build.result == 'success' || needs.build.result == 'skipped') }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
          aws-region: eu-central-1
      - run: ./deploy --image "${{ inputs.image }}@${{ inputs.digest || needs.build.outputs.digest }}"

Callers reference your own templates by a moving major tag such as @v3, never @main, so patches and security fixes on that major land without a coordinated bump. Third-party actions use their major tag for the same reason. A commit SHA anywhere is debt that needs a written reason. This is separate from image digests: the app artifact still promotes by sha256.

# org/payment-service/.github/workflows/ci.yml (illustrative)
on:
  pull_request:
permissions:
  contents: read
jobs:
  test:
    uses: org/platform-workflows/.github/workflows/test.yml@v3
# org/platform-workflows/.github/workflows/test.yml (illustrative)
on:
  workflow_call:
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: ./test
# org/payment-service/.github/workflows/staging.yml (illustrative)
on:
  push:
    branches: [main]
permissions:
  contents: read
  id-token: write
  packages: write
jobs:
  build-and-stage:
    uses: org/platform-workflows/.github/workflows/web.yml@v3
    with:
      environment: staging
      image: ghcr.io/org/payment-service

The staging caller grants packages: write so the reusable build job can push to GHCR with GITHUB_TOKEN. The callee cannot request a scope the caller did not grant. Promote does not build, so it does not grant packages: write.

Pin a commit SHA on your template only when you must stay on one exact revision. Say why, and treat it as debt:

# Stay on this SHA: v3.4.0 on the v3 line ships a cache bug this service cannot take.
# Remove when v3.4.1 is on the v3 tag.
uses: org/platform-workflows/.github/workflows/web.yml@9f86d081884c7d659a2feaa0c55ad015a3bf4f1b

Pull requests call test.yml. They do not set an Environment and they do not call web.yml. github.event_name inside a workflow_call is workflow_call, so you cannot gate deploy on "this is a PR" from inside web.yml. Staging is push to main. The promote job is workflow_dispatch on main. The production Environment has required reviewers and a deployment-branch policy that names only that branch. Fork pull requests never reach it.

Promotion and rollback

Omit digest on staging so the template builds, pushes a sha256 to GHCR or ECR, and deploys after merge to main. Pass it on production so the template skips the build. Production is a workflow_dispatch (or an Environment wait timer). Rollback is dispatch of the previous digest, not a rebuild of main. That rolls back the image. It does not roll back a database migration, a queue contract, or config that never lived in the artifact.

# org/payment-service/.github/workflows/promote.yml (illustrative)
on:
  workflow_dispatch:
    inputs:
      digest:
        description: sha256 already running in staging
        required: true
        type: string
permissions:
  contents: read
  id-token: write
jobs:
  production:
    uses: org/platform-workflows/.github/workflows/web.yml@v3
    with:
      environment: production
      image: ghcr.io/org/payment-service
      digest: ${{ inputs.digest }}
Two-column diagram: production rebuilds from main and deploys a latest tag on the left, versus one image digest built once, run in staging, then dispatched to production with rollback as a previous digest on the right
Figure 2. Staging proof only binds production if it is the same digest. Rollback is that digest's predecessor, not a rebuild of main.
Three-stage CI/CD pipeline promotion: pull requests run isolated tests, merge to main builds digest and deploys to staging via OIDC, and production dispatch promotes the same digest with reviewers
Figure 3. Pull requests run tests. Staging is merge. Production is a digest plus reviewers. Neither deploy job rebuilds.

Jenkins freeze

The leftover batch job stays on Jenkins until it needs a behaviour change. Freeze means no new jobs, no new plugins, no second CI product. Security updates to Jenkins core and the plugins already installed still land until the job is gone. Unpatched Jenkins is a security finding in its own right. Credentials on that job are rotated or left to die with the job. When that job next needs a behaviour change, it moves onto the worker template. New services are Actions only.

Who owns what

ConcernOwnerArtifact
Template repo and CODEOWNERSPlatformorg/platform-workflows, required reviewers on template PRs
Environment rulesPlatform, plus the service owner on productionStaging and production Environments, required reviewers, deployment branches
Deploy-role IAM policyApp team, inside the platform boundaryScoped role per Environment; trust and sub in the RBAC note
AuditPlatform / security operationsOrg audit-log streaming into the SIEM, not the Actions UI

The IaC caller

The IaC template is a thin caller: plan on the pull request, apply only on the Environment that owns that root, pin the module ref. State buckets, workspaces, and backend isolation are in Terraform module patterns for multi-environment cloud estates. This note does not re-teach them.

Is the standard holding?

Standardisation is holding when a new service can adopt an existing template with a handful of inputs, pull requests run ci.yml, the last production deploy was a digest that already ran in staging, rollback is dispatch of the previous digest, and a new joiner can explain the path from the caller files without a hallway tour.

It is not holding when every service has a temporary workflow, production credentials outlive the pipeline that created them, callers still track @main, or the template repo has no CODEOWNERS.

What to check Monday

  • Grep uses: for @main. There should be none.
  • Confirm callers use @v3 unless a commented SHA pin exists, with a reason, treated as debt.
  • Confirm third-party uses: (outside actions/*) use their major tag, and that any SHA pin carries a written reason.
  • Confirm the production deployment environment has required reviewers and a deployment-branch policy. Confirm fork pull requests cannot select it. Confirm pull requests call only ci.yml.
  • Confirm staging and production OIDC roles differ. Match the token's sub: classic environment:staging / environment:production, or the immutable repo:org@id/name@id: prefix on repos created after 15 July 2026.
  • Confirm no AWS_ACCESS_KEY_ID in Actions secrets, no registry PAT, and no secrets: inherit on reusable workflow calls. Confirm the staging caller grants packages: write if it pushes to GHCR.
  • Confirm Jenkins has no new jobs this quarter. Confirm Jenkins core and installed plugins have current security updates.
  • Confirm the last production deploy used the same image digest that ran in staging.

This note is the delivery side of Data and delivery engineering. Module and environment boundaries for the IaC caller are in Terraform module patterns for multi-environment cloud estates. Deploy-role trust and the sub claim live in CI/CD roles with OIDC. For rollback-safe schema migrations in the pipeline, see Zero-downtime database migrations with GitHub Actions.