Building on AWS · guide 03 of 13
Infrastructure CI/CD and Change Gates That Actually Hold
Abstract. In a startup on AWS, the infrastructure pipeline is usually the most privileged principal in the company. It can create IAM roles, rewrite KMS key policies and delete databases, and it does all of this without supervision, a few minutes after a merge. This article is for senior platform, DevOps and SRE engineers who own that pipeline, and particularly for those at crypto and web3 companies, where the same AWS account may hold transaction-signing keys and hot-wallet credentials. It covers five things:
- Federation. Exactly how GitHub Actions should federate into AWS with OIDC, and how to scope the
subclaim. - Plan roles. Why the read-only plan job is an attack surface, and how to split it.
- Deploy-role permissions. How to bound deploy roles when least privilege isn’t achievable yet.
- Approval gates on GitHub Team. How to build a gate that fails closed when GitHub’s native required reviewers aren’t available on your plan, and how to keep machine-generated deploys from turning it into rubber-stamp noise.
- The quiet failures. The ones that make a broken pipeline look green: dropped push events, skipped jobs, stale-state applies, pending changes swept into an unrelated apply.
The patterns come from a mature production platform at a mid-stage crypto infrastructure company, generalized and stripped of identifying detail, and from primary sources at AWS, GitHub, HashiCorp, NIST, OWASP and others.
1. The problem space
1.1 The pipeline is the crown jewel
A typical Terraform pipeline on GitHub Actions ends up with these properties:
- It holds credentials that can do almost anything in the AWS account, often
AdministratorAccess. - It runs arbitrary code from the repository. Workflows, scripts, provider plugins and
externaldata sources all execute on the runner. - It reads every secret that the Terraform roots consume: database passwords, 1Password/Vault tokens, Slack webhooks, Git deploy keys.
- It runs on every pull request (plan) and on every merge to
main(apply).
OWASP’s Top 10 CI/CD Security Risks reads like a list of these properties [1]:
- CICD-SEC-1, Insufficient Flow Control
- CICD-SEC-2, Inadequate IAM
- CICD-SEC-4, Poisoned Pipeline Execution
- CICD-SEC-5, Insufficient Pipeline-Based Access Controls
- CICD-SEC-6, Insufficient Credential Hygiene
NIST SP 800-204D makes the same point from the supply-chain side: the CI/CD system is itself part of the supply chain and needs the controls you would put on production [2].
For a crypto company the stakes are sharper. When the pipeline’s role can call kms:Sign on a key that controls funds, the CI system is a wallet, whether you planned it that way or not. “We deny kms:ScheduleKeyDeletion to CI” sounds reassuring, but deletion isn’t the threat model for a signing key. Signing is.
1.2 The forces
| Force | What it pushes toward |
|---|---|
| Team of 3–10 engineers, nobody full-time on security | Few moving parts, controls that run themselves, no second approver on every change |
| Shipping speed (dozens of merges a day, most of them image bumps) | Gates that don’t fire on routine changes, or people will route around them |
| A single AWS account, because that’s how the company started | IAM Deny patches standing in for account boundaries |
| A GitHub Team plan | No native required reviewers on private-repo environments (§4.4) |
| Partner and auditor pressure (SOC 2 CC8.1, exchange due diligence, custody partners) | An audit trail for every production change, and evidence that “approval” means something |
| Crypto key custody | Separating fund-moving keys from deploy credentials, not just from humans |
None of these go away. The design below doesn’t try to win every trade-off. It is explicit about which ones it loses, and it watches the losses.
2. Reference design
2.1 Shape
The building blocks:
- One IAM OIDC provider for
token.actions.githubusercontent.com, managed by a “shared” or “bootstrap” root that nothing else can modify. - Three classes of role, each with its own trust:
plan-pr: trusted only forpull_requestsubjects. Read-only, with no secret access and no state for the most sensitive roots.plan-main: trusted only forref:refs/heads/main. Read-only, plus a named allow-list of secrets that plans really need. Drift detection uses this role.deploy-<env>: one per Terraform root, trusted only forenvironment:<env>. Each GitHub environment is restricted tomainby a deployment-branch policy.
- Guardrail Deny policies on every deploy role, for actions that are irreversible or cross environments.
- A two-tier apply. Ungated roots (dev) apply first, unattended. Gated roots (prod, shared) apply after one of two checks: a human approval, or a verification that nothing is unresolved.
- Serialization. Applies to the same root share a concurrency group, and matrices run with
max-parallel: 1in dependency order. - Independent watchers. A scheduled refresh plan catches drift. A coverage reconciler catches merged commits that were never applied.
2.2 Trust policies
Deploy role trust, one per environment:
data "aws_iam_policy_document" "deploy_trust" {
for_each = toset(var.deploy_envs) # ["dev", "dev-apps", "prod", "prod-apps", "shared", ...]
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# Exact match. Only a job that entered GitHub environment <env> gets this role.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:example-org/infra:environment:${each.key}"]
}
}
}
resource "aws_iam_role" "deploy" {
for_each = toset(var.deploy_envs)
name = "ci-deploy-${each.key}"
assume_role_policy = data.aws_iam_policy_document.deploy_trust[each.key].json
max_session_duration = 14400 # long RDS modifications and rollout watches outlive 1h
}
Plan role trust, split in two:
# PR plans: arbitrary, unreviewed code runs with these credentials.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:example-org/infra:pull_request"]
}
# main-only plan / drift role: only reviewed code runs with these credentials.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:example-org/infra:ref:refs/heads/main"]
}
These subject formats are the ones GitHub documents [3]:
repo:ORG/REPO:environment:NAMErepo:ORG/REPO:pull_requestrepo:ORG/REPO:ref:refs/heads/BRANCHrepo:ORG/REPO:ref:refs/tags/TAG
IAM refuses a GitHub trust policy whose sub condition is missing, or is only a wildcard [4]. Two notes on the subject format:
- Repositories created after mid-2026 use an immutable subject format that embeds owner and repository IDs (
repo:org@123/repo@456:...) [3]. Template your trust policies from the actual token, not from a blog post. - When a job declares
environment:, its subject is the environment form, not the ref form. That’s what makes environment-scoped trust work. It also means a job that needs both a deploy role and a ref-scoped role can’t have both.
2.3 Guardrail Deny, abridged
data "aws_iam_policy_document" "deploy_guardrails" {
for_each = toset(var.deploy_envs)
statement { # no new long-lived credentials / console logins
sid = "DenyIamCredentialCreation"
effect = "Deny"
actions = ["iam:CreateUser", "iam:CreateAccessKey", "iam:UpdateAccessKey",
"iam:CreateLoginProfile", "iam:UpdateLoginProfile",
"iam:CreateVirtualMFADevice", "iam:DeactivateMFADevice"]
resources = ["*"]
}
dynamic "statement" { # every env except the one that owns this file
for_each = each.key == "shared" ? [] : [1]
content {
sid = "DenyCiRoleTampering"
effect = "Deny"
actions = ["iam:UpdateAssumeRolePolicy", "iam:AttachRolePolicy", "iam:PutRolePolicy",
"iam:DetachRolePolicy", "iam:DeleteRolePolicy", "iam:DeleteRole",
"iam:PutRolePermissionsBoundary", "iam:DeleteRolePermissionsBoundary"]
resources = ["arn:aws:iam::${local.account_id}:role/ci-*"]
}
}
statement { # no role chaining out of the CI session
sid = "DenyRoleAssumption"
effect = "Deny"
actions = ["sts:AssumeRole", "sts:AssumeRoleWithSAML"]
resources = ["*"]
}
statement { # KMS: no deletion / repointing of keys this env doesn't own
sid = "DenyKmsDestructiveExceptOwn"
effect = "Deny"
actions = ["kms:ScheduleKeyDeletion", "kms:DisableKey", "kms:PutKeyPolicy"]
not_resources = local.kms_owned_by[each.key]
}
statement { # crypto-specific: CI never signs or decrypts with fund-moving keys
sid = "DenySigningKeyUse"
effect = "Deny"
actions = ["kms:Sign", "kms:Decrypt", "kms:GenerateDataKey*"]
resources = local.signing_key_arns
}
statement {
sid = "DenyAuditTeardown"
effect = "Deny"
actions = ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail",
"config:StopConfigurationRecorder", "config:DeleteConfigurationRecorder",
"config:DeleteDeliveryChannel"]
resources = ["*"]
}
statement {
sid = "DenyOrganizations"
effect = "Deny"
actions = ["organizations:*"]
resources = ["*"]
}
}
An explicit Deny in an identity policy always beats an Allow in another identity policy, including AdministratorAccess [5]. That is the whole mechanism. Its limits are covered in §4.3.
2.4 The apply workflow skeleton
on:
push:
branches: [main]
paths: ['terraform/**', 'helm-charts/**']
workflow_dispatch:
inputs:
environments: { type: string, required: false } # recovery: re-select a root
skip_approval: { type: boolean, default: false } # approvers only, see §4.4
permissions:
contents: read
id-token: write
issues: write # approval issue
deployments: read # unresolved-failure check
jobs:
detect-changes: # git diff HEAD^ HEAD → ordered ungated/gated matrices + needs_approval
apply-ungated:
needs: detect-changes
strategy: { max-parallel: 1, matrix: { environment: ${{ fromJson(needs.detect-changes.outputs.ungated) }} } }
environment: ${{ matrix.environment }}
concurrency: { group: tf-apply-${{ matrix.environment }}, cancel-in-progress: false }
await-approval:
needs: [detect-changes, apply-ungated]
if: always() && needs.detect-changes.outputs.needs_approval == 'true'
&& (needs.apply-ungated.result == 'success' || needs.apply-ungated.result == 'skipped')
steps:
- uses: ./.github/actions/await-approval
apply-gated:
needs: [detect-changes, apply-ungated, await-approval]
strategy: { max-parallel: 1, matrix: { environment: ${{ fromJson(needs.detect-changes.outputs.gated) }} } }
environment: ${{ matrix.environment }}
concurrency: { group: tf-apply-${{ matrix.environment }}, cancel-in-progress: false }
steps:
- name: Verify no unresolved apply failure
if: needs.detect-changes.outputs.needs_approval == 'false'
- name: terraform apply
The rest of this article explains why each of these lines exists.
3. Federation: getting OIDC right
Static AWS_ACCESS_KEY_ID secrets in CI have been an anti-pattern for years. GitHub and AWS both document OIDC federation as the replacement [6][4]. The migration itself is easy. The hard part is what the trust policy actually says.
3.1 Choose the subject dimension deliberately
sub form | Who can mint it | Good for | Trap |
|---|---|---|---|
repo:o/r:* (StringLike) | Any workflow, any branch, any PR, any environment in the repo | Nothing privileged | Any contributor’s branch gets the role. IAM accepts it because it isn’t a bare * |
repo:o/r:ref:refs/heads/main | Workflows running on main (push, schedule, dispatch-from-main) | Read roles for reviewed code: drift plans, coverage checks | Doesn’t apply to jobs that declare environment:, since their subject changes |
repo:o/r:pull_request | Every PR run, including unreviewed code | PR plan role only | Treat it as “anyone with write access, or a fork if you allow fork workflows” |
repo:o/r:environment:prod | Jobs that enter the prod environment | Deploy roles | Only as strong as the environment’s branch policy. An environment with no branch restriction can be entered from any branch |
AWS’s own guidance for GitHub environments is the important bit: if a trust policy references an environment, add deployment branch rules to that environment [4]. Without them, environment:prod means “any branch that writes environment: prod in a workflow file”, which is every branch.
One team learned the cost of ordering here. GitHub creates an environment the first time a workflow references it, with no protection. If the OIDC trust already names that environment, the first run from any branch succeeds. Create environments and their branch policies with a script or with Terraform’s GitHub provider, before the first workflow that references them merges.
3.2 Bootstrap order
OIDC migrations break their own PR. A pull_request workflow runs the workflow file from the PR branch, so the PR that switches to role-to-assume needs the roles to exist already. Order:
- Apply the OIDC provider, roles and EKS access entries from a human session.
- Merge the workflow change.
- Migrate remaining static-key consumers (often a test suite in another repository).
- Deactivate the old key, wait a few days, delete it.
Kubernetes adds a second trap. IAM permissions don’t grant cluster access. With EKS access entries (authenticationMode: API), a role with AdministratorAccess and no access entry gets HTTP 401 from the cluster API. Every CI role that runs helm or kubectl, or uses the Kubernetes Terraform provider, needs an access entry on every cluster it touches. That includes clusters it only reads: one team’s app-promotion workflow polled ArgoCD on a separate management cluster, and every promotion broke on the day OIDC went live. The access-entry table is your real inventory of CI cluster access, so keep it in one file. The plan role needs AmazonEKSAdminViewPolicy, not AmazonEKSViewPolicy, because plans read Helm release state, which is stored in Secrets.
3.3 One residual you cannot Deny away
A well-meant guardrail reads: deny sts:AssumeRole* so a compromised CI session can’t pivot into another role. The guardrail in §2.3 denies sts:AssumeRole and sts:AssumeRoleWithSAML, but not sts:AssumeRoleWithWebIdentity. AWS documents that for AssumeRoleWithWebIdentity “there are no policies to evaluate because the caller of the API is not an AWS identity” [7].
A CI job already holds a GitHub OIDC token. If it can create a new role that trusts the same OIDC provider (iam:CreateRole is usually allowed, because every root creates IRSA roles), it can attach admin to that role and assume it directly with its token. Nothing on the original role’s identity policy is ever consulted. Closing this needs one of the following:
- A permissions boundary condition on
iam:CreateRole.iam:PermissionsBoundarymust equal a restrictive boundary [8], applied to every role-creating root. - An SCP in a multi-account organization that denies creating roles which trust the GitHub provider outside a named path [9].
- Splitting the deploy roles across accounts, so the blast radius of “create any role” is one environment.
Write the residual down. A team that believes its Deny list is complete will stop looking.
4. Decisions and trade-offs
4.1 Plan role vs apply role: PR plans are an attack surface
The trap. “Plan is read-only, so the plan role is safe to hand to every PR.” Both halves are wrong.
- Plan executes code. HashiCorp’s security model for HCP Terraform says it directly: “Terraform
planandapplyoperations can execute arbitrary code within the ephemeral build environment.” HashiCorp recommends disabling speculative plans when untrusted users can open PRs [10]. What runs:externaldata sources- custom or forked providers
templatefileover attacker-controlled paths- the workflow’s own
run:steps, which a PR can rewrite
- Plan reads secrets. The state file holds every attribute Terraform manages: database master passwords, generated keys, connection URIs. HashiCorp’s automation guide notes that a saved plan file contains “a full copy of the configuration, the state that the plan applies to, and any variables” [11]. Data sources that read Secrets Manager need
GetSecretValue. Providers for RabbitMQ, PostgreSQL or 1Password need live credentials passed asTF_VAR_*.
What one review found on a real platform, a year after OIDC had been “done”:
- The PR-trusted plan role held
secretsmanager:GetSecretValueandkms:Decrypton*. - It also had SSM port-forwarding to the production bastion and the state bucket’s CMK.
- Among the secrets it could read was a credential for a wallet that was still funded, belonging to a feature that had been decommissioned months earlier.
- PR plan jobs also received a Git deploy key, an SSO client secret, Slack webhooks and, on two observability roots, a production 1Password service-account token.
Any engineer with write access could open a PR that added a data "external" block and exfiltrated all of it. No review or merge was needed.
Options.
| Option | Upside | Downside | Cost |
|---|---|---|---|
A. One plan role for PR and main, broad read | Simple. Plans always work | PR = read every secret | None until the incident |
B. Same role, but GetSecretValue scoped to a named ARN list | Cuts out the worst (stray wallet keys, other regions) | State and DB credentials still readable. The list must be maintained: every new secret data source must be added, or plans fail with AccessDenied | Low |
C. Split: plan-pr (no secrets, no prod state, or prod state without decrypt), plan-main (named secrets) | PRs can’t read prod secrets. Drift plans keep full fidelity | PR plans for prod roots lose fidelity or can’t run at all | Medium |
| D. No automatic PR plans for prod. Plan runs only after a maintainer adds a label, or after merge into a protected pre-prod branch | Strongest | Slower feedback. Reviewers approve without seeing a plan | Medium |
| E. Separate accounts. PR plans run against a non-prod account only | Structural | Requires multi-account | High (but you want it anyway) |
Recommendation by stage.
- Seed: B, today. It’s a one-file change.
- Series A: C. You’ll find that most roots plan fine without secrets if providers that need live credentials (database, message broker, Kubernetes) are isolated into small “-apps” roots.
- At scale: E plus D for the production account.
Secrets in the job environment are part of the same surface. A GitHub secret referenced in a job’s env: is visible to every step of every matrix leg that shares that block. Two rules:
- Scope per matrix leg in the expression itself:
One team found that the production vault’s read-write credential had been injected into all seven plan legs for weeks. The ticket to fix it had been closed “Done” after a docs-only change. Verify a fix withenv: OP_TOKEN_PROD: ${{ (matrix.environment == 'prod' || matrix.environment == 'prod-apps') && secrets.OP_TOKEN_PROD || '' }}grep; a ticket status proves nothing. - Prefer Secrets Manager over GitHub secrets for credentials a plan needs. Read them with a data source under an IAM allow-list. You get CloudTrail per read and a single place to revoke, and you never materialize the value as an environment variable.
When to revisit:
- the plan role’s secret list grows past roughly 20 names
- any wallet or signing material shows up in a secret the plan role can read
- you allow fork PRs
- an auditor asks “who can read the production DB password?” and the answer includes “any PR”
4.2 Plan-then-apply fidelity
Most GitHub-native Terraform pipelines re-plan at apply time (auto_approve: true) rather than applying the saved plan the reviewer saw. Many also set refresh: false on apply for speed. Both choices have consequences:
- Re-plan means the apply isn’t the reviewed plan. Whatever is pending in the state of that root gets applied: an earlier failed apply’s destroys, or a resource replacement caused by a provider upgrade. The PR diff never showed it.
refresh=falseplans against stored state. HashiCorp: it “causes Terraform to ignore external changes, which could result in an incomplete or incorrect plan” [12]. Repeated apply failures on the same resource are often stale state rather than a code bug. Check live AWS before you “fix” the code.
Options:
- Saved plan artifacts. Plan on merge, store the
.tfplan, apply exactly that plan after approval. You get maximum fidelity, but the plan file contains state and secrets, so the artifact store must be protected like the state bucket [11]. It goes stale if anything else applies first, so you need serialization (§4.6). - Re-plan, plus controls that catch the gap. An approval issue that tells approvers that pending changes from earlier merges may appear. A “verify no unresolved failure” check (§4.5). A daily refresh plan (§4.8). This is what most small teams run, and it’s defensible if those controls exist.
Revisit when an incident involves “the apply did something the PR didn’t show.” That incident is the signal to move gated roots to saved plans.
4.3 AdministratorAccess + explicit Deny vs least-privilege allow-lists
Options:
| Option | What it buys | What it costs | When it fits |
|---|---|---|---|
A. AdministratorAccess per env, nothing else | Plans never fail on permissions | Any root can destroy any other root’s resources in the same account | Never past the first month |
| B. Admin + Deny guardrails | Closes named irreversible and cross-environment paths: IAM users/keys, CI-role trust tampering, KMS deletion/policy writes outside the root’s own keys, CloudTrail/Config teardown, Organizations | Everything not named is still allowed. Deny lists rot as new services appear | Seed to Series A, single account |
| C. Per-root least-privilege allow-list | True least privilege (AWS Well-Architected SEC03-BP02 [13]) | Deriving ~10 allow-lists needs a real apply per root to discover the missing action. IAM Access Analyzer policy generation from CloudTrail helps [13] | Once roots are stable and applies are routine |
| D. Separate accounts + SCPs | Removes whole classes of findings. An SCP Deny applies “even if the account administrator attaches the AdministratorAccess IAM policy” [9] | Migration effort, cross-account networking, more Terraform providers | Series A onward, especially for crypto |
One independent review of a single-account platform traced nearly every critical and high finding back to “admin anywhere = admin everywhere”. That included signing-key exposure, broad plan-role reads, ECR push roles shared across repositories, and audit-log tamper paths. The structural fix was per-environment accounts: prod, non-prod, shared tooling, security/audit, logs. The Deny list was a patch that bought time.
Crypto-specific: guardrails for destruction aren’t guardrails for misuse. The guardrails in one real deployment denied kms:ScheduleKeyDeletion, kms:DisableKey and kms:PutKeyPolicy everywhere except the root that owned each key. They did not deny kms:Sign. The un-gated dev deploy role, admin across the single account, could therefore sign with a production key whose key policy started from the standard root: kms:* statement and had no principal restriction. The key in question was a privileged contract-owner key. To close this:
- Add
kms:Sign/kms:Decrypt/kms:GenerateDataKey*to the Deny on every non-prod deploy role, scoped to signing-key ARNs (see §2.3). - Put a principal allow-list in the key policy itself. Use a Deny for everyone except named workload roles, using
aws:PrincipalArn. Key policies are resource-based, so this holds even against admin identity policies in the same account. - Don’t share keys across environments. A fee-payer key “owned” by dev state and used by prod puts prod funds under dev’s change process.
The Deny-list mechanics that bite:
- Carve out by owner, not by role.
not_resources = local.kms_owned_by[env]lets each root manage its own keys while it can’t touch anyone else’s. A blanket exemption for “roots that own keys” lets dev repoint prod’s key. - The root that owns the CI roles must be exempt from the tampering Deny. Otherwise it can’t apply its own file. That makes this root the most sensitive in the repo: gate it like prod, and give it narrower CODEOWNERS.
- Denying a destructive action that’s part of a legitimate lifecycle is deliberate. Retiring a KMS key or decommissioning a trail now requires a human MFA session. That is the point of the guardrail.
- Keep the residual list in the policy file (see §3.3). The next engineer needs to know what the Deny doesn’t cover.
Revisit when:
- you add a second production workload with separate custody
- an auditor scopes SOC 2 to production only
- the Deny list passes about 10 statements
- the first time a guardrail misses something it “should” have caught
4.4 Approval gates on GitHub Team: build one that fails closed
The platform constraint. GitHub’s environment deployment protection rules, such as required reviewers and wait timers, are available for private repositories only on GitHub Enterprise. On Free, Pro and Team they apply to public repositories only, although environments, environment secrets and deployment branch policies do work on private repositories under Team [14]. Teams discover this when their setup-environments script gets HTTP 422 on the reviewers field.
A warning about how this goes wrong: at least one team had a ticket titled “production approval gate” marked Done for months, while every production environment carried only a branch policy. The call that would have added reviewers had failed, and nobody re-checked. Verify a gate by trying to get past it.
Options:
| Option | Upside | Downside |
|---|---|---|
| Upgrade to Enterprise | Native, platform-enforced, “prevent self-review” toggle [15] | Per-seat cost, often 4–5× Team |
| Third-party “manual approval” action | Quick | Another third-party action holding issues: write in a job that can reach deploy credentials. Supply-chain risk |
| Issue-comment gate in a first-party composite action | No plan change, auditable, reusable across workflows | Workflow code, not a platform control: anyone who can edit the workflow can delete it |
| Gate in an external system (Slack app, ChatOps, a custom deployment protection rule backed by your own GitHub App) | Can enforce separation of duties | You now operate an approval service |
A reference issue-comment gate. These are the properties that matter; each one is there because its absence caused a real bug somewhere.
- Open an issue that carries what the approver needs. Commit subject, author, run URL, the file list and a link to the PR plan. Add an explicit warning: a prod apply can also execute changes left pending by an earlier merge, which won’t appear in this commit’s diff.
- Announce it in Slack, with every value passed through
jq --arg. Commit subjects are attacker-influenced and contain backticks. - Decide over structured comment objects, never over text lines. One early version paired alternating
authorandbodylines fromghoutput. A non-approver posting the multi-line commentx\nalice\napprovedforged an approval fromalice. The fix is a jq filter over{author.login, body}objects:
The whole body must be exactly($approvers | split(",")) as $ok | [ .comments[] | {login: .author.login, verdict: (.body | ascii_downcase | gsub("^\\s+|\\s+$"; ""))} | select(.login as $a | $ok | index($a)) | select((.login | ascii_downcase) != (env.REQUESTER // "" | ascii_downcase)) | select(.verdict == "approved" or .verdict == "denied") ] | first | if . == null then "" else .verdict + " " + .login endapprovedordenied. “LGTM, approved” doesn’t count. Share one filter between every gate (Terraform apply and app promotion) and unit-test it, so the gates can’t drift apart. - Approvers are an explicit list, kept with the gate (Google’s release-engineering practice likewise separates who may approve, release and deploy [24]). It isn’t “anyone with write access”. Note that GitHub logins and IAM usernames rarely map 1:1, which is a reason to move both to SSO groups eventually.
- Fail closed. Poll every 20–30 seconds. If no decision arrives within 60 minutes, close the issue and exit non-zero. An approval that never came isn’t an approval. Recovery means a re-run. The cost is real: a late-night merge needs a re-dispatch in the morning. Accept it.
- The emergency bypass is itself gated.
skip_approvalis aworkflow_dispatchinput. Anyone with write access can set it, which is a much wider group than the approvers. The bypass step must check thatGITHUB_ACTORis on the approver list, fail otherwise, and log a warning naming the actor. Without that check, the bypass is a second approval path with no guard. - Optionally exclude the requester (
exclude_requester), so a person can’t approve their own dispatch.
Be honest about the limits. The gate turns an unattended apply into a deliberate act with an audit trail. It isn’t separation of duties:
- One person can author, merge and approve.
- Anyone who can edit
.github/can remove the gate in the same PR that exploits its absence.
NIST SP 800-53 AC-5 and change-control frameworks expect more [16]. At startup size, the realistic compensating controls are:
- Required code-owner review on
/.github/, IAM/OIDC files and signing-key definitions, from a short owner list. One review found CODEOWNERS mapping*to all nine org members, with code-owner review not required, so it gated nothing. - Branch protection or rulesets that require review and status checks, which OpenSSF Scorecard’s Branch-Protection and Code-Review checks measure [17].
- An alert on changes to gate files, or a scheduled job that checks the gate’s own presence.
4.5 When to gate: avoiding gate fatigue without opening holes
A gate that fires on every merge gets deleted, or approved without reading. DORA’s research on change approval is blunt: heavyweight external approval correlates with worse delivery performance, because it increases batch size and lead time. DORA recommends peer review embedded in the code platform, plus automated detection [18]. The goal is to gate the changes peer review can’t evaluate, and nothing else.
Gate the right roots. Use an explicit allow-list of gated roots (prod, prod-apps, shared, shared-apps). An allow-list is easy to reason about, but it fails open for a new production root. Adding a root to the apply order doesn’t gate it; you have to add it to the list too. Put a test or a comment next to the list. A deny-list (“everything except dev”) fails closed, but it gates the management cluster’s app definitions and every new experimental root. Pick one deliberately.
Exempt machine-generated image promotions by diff content, not by path or actor. In one platform, image-tag promotions were roughly 75–80% of all pushes to main. They were already approved in the CD pipeline before the promotion PR merged, and the Terraform apply they triggered was a no-op: Terraform listed the values files by path and never read their content, while ArgoCD picked up the new tag straight from git. Gating them would have meant a second approval on every deploy. The exemption went through two broken versions:
- Path-based. “Any change under
apps/*/values-<env>.yamlis a promotion.” Rejected in review, correctly: those same files carry environment variables, secret references, NetworkPolicy rules and resource limits. A PR changing only a prod env var would have been exempt, and ArgoCD would have applied it frommainwith no gate. - Actor-based. “Merged by the automation bot means a promotion.” Also rejected: it skips content inspection entirely, so a bot-authored merge that wasn’t purely a promotion would apply unattended.
The version that held is a small classifier over git diff -U0:
# Exit 0 = promotion only (gate not required). Exit 1 = anything else.
VALUES = re.compile(r"^deploy/apps/[^/]+/values-(?!common\.)[^/]+\.yaml$")
IMAGE_TAGS = re.compile(r"^terraform/[^/]+/image-tags/[^/]+$")
TAG_LINE = re.compile(r"^[+-][0-9a-f]{7,40}$") # image-tags file = one bare sha
REMOVED_OK = re.compile(r"""^-[ ]*( tag:[ ]*["']?[\w.-]+["']?
| example\.com/deploy-diff:[ ].*
| hotfixNonce:[ ].*
| skipAnalysis:[ ].*
| rollout:([ ]*\{\})? )$""", re.X)
ADDED_OK = re.compile(r"""^\+[ ]*( tag:[ ]*["']?[\w.-]+["']?
| example\.com/deploy-diff:[ ].*
| hotfixNonce:[ ].*
| rollout:([ ]*\{\})? )$""", re.X) # NOTE: no skipAnalysis
The design rules, each from a review finding:
- Allow-list the exact keys the automation writes, and nothing more. One extra changed line anywhere in the commit re-arms the gate.
- Asymmetry for safety-relevant keys. Removing
skipAnalysisrestores the canary/analysis gate, so it’s exempt. Adding it strips post-promotion analysis from the rollout, so it’s never exempt, even though the automation is what writes it. - Validate content, not just presence. A changed line in an image-tags file must be a bare SHA. Otherwise arbitrary content written to that file would be exempt.
- Deleting a tag file isn’t a promotion. In one setup, a missing tag file made the chart fall back to
latest. - Exclude cross-environment layers. A shared
values-common.yamlfans out to every environment, and automation never writes it. - Fail closed everywhere. Unknown path, unparsable diff, empty diff, odd
diff --githeader: all of them gate. - Unit-test it with real promotion diffs and near-miss diffs.
The “PR review is the approval” carve-out, and its cost. One team decided that a commit touching only gated roots skips the issue gate, on the grounds that a dedicated prod PR was already reviewed as a prod change. Asking twice is the friction that gets gates removed. The gate still fires when a prod-tier change rides along with dev changes, where a reviewer is more likely to skim. This is a reasonable trade-off, but it knowingly reopens the failure class the gate was built for: a reviewed, prod-only merge whose apply includes unrelated pending changes. The next control is what makes it survivable.
4.6 “Verify no unresolved apply failure”
The incident that justifies the whole gate looked like this. A PR adding an IAM Access Analyzer was reviewed, merged and applied. The apply also executed three IAM user deletions left pending by an earlier PR whose own apply had never succeeded. Nobody reviewed those deletions at apply time, because the PR diff didn’t contain them.
The generalized defense: before any unattended apply to a gated root, check that the root’s most recent apply attempt, from any other commit, succeeded. GitHub already records this. Every job that declares environment: creates a Deployment with statuses, so no new bookkeeping is needed:
ID=$(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=$ENV&per_page=10" \
--jq ".[] | select(.sha != \"$GITHUB_SHA\") | .id" | head -1)
STATE=$( [ -n "$ID" ] && gh api "repos/$GITHUB_REPOSITORY/deployments/$ID/statuses?per_page=1" --jq '.[0].state // empty')
if [ -n "$STATE" ] && [ "$STATE" != success ]; then
echo "::error::$ENV: last prior apply ended '$STATE'. Fix it, then dispatch with environments=$ENV skip_approval=true."
exit 1
fi
Three details that each came from a missed case:
- Don’t filter to the parent commit. A failed apply from an older commit, followed by commits that didn’t touch this root, leaves no deployment at the parent SHA, so the check would pass silently. Use “most recent deployment that isn’t this run’s own”.
- Run the check inside the apply job, after the concurrency group admits it. A pre-check in
detect-changeseither races (the previous apply is stillin_progress) or goes stale before the queued job runs. Once a job in a per-root concurrency group is running, everything ahead of it in that root’s queue has reached a terminal state, so this is the one race-free point. - It’s sticky by design. After one failed apply, every later unattended apply to that root refuses, including plain re-runs. Only a run through the issue gate, or an approver’s dispatch with
skip_approval, clears it. Write the recovery procedure as a runbook or skill. The error message should contain the exact dispatch command, because detection bygit diff HEAD^ HEADwon’t re-select the root oncemainhas moved on.
4.7 Serialized apply matrices and ordering
Terraform roots depend on each other through remote-state outputs. A sensible default:
- One fixed order (bootstrap/shared → env base → env apps → GitOps definitions), applied with
max-parallel: 1. - Ungated roots first, unattended, before the approval prompt even appears. If dev sits behind the same approval wait as the prod change it happened to ship with, there’s no point leaving dev unattended. Splitting into two matrices lets the gated job
needthe ungated one. - GitOps-definition roots go after the gated tier, so they read fresh shared outputs. Putting them in the early ungated tier made them read stale outputs.
- A per-root concurrency group with
cancel-in-progress: false, so a running apply is never killed mid-flight.
The concurrency default you must know about. In a concurrency group, GitHub by default keeps at most one pending run. A newer run cancels the pending one and takes its place [19]. With per-commit change detection (git diff HEAD^ HEAD), a cancelled pending apply means that commit’s root changes are never applied by anyone, because the next run diffs only its own commit. Mitigations:
- Use the newer
queue: maxsetting where available [19]. - Detect changes against the last successful deployment SHA for each root rather than
HEAD^. - Rely on the coverage reconciler in §4.8 to catch what slips through.
Any out-of-band apply path, such as a promotion script running terraform apply -target -refresh=false, must use the same concurrency group. Otherwise it collides on the state lock and hides drift.
4.8 Watchers: drift plans and apply-coverage reconciliation
Two independent scheduled jobs catch what the event-driven pipeline can’t.
Daily refresh plan (drift detection). PR plans keep refresh: false, because they answer “what does this diff change?” and need to be fast. The scheduled plan must use refresh: true. A non-refreshing drift plan reports “no changes” forever, because it can’t see anything that changed outside Terraform, and drift is exactly that [12]. Expect the first runs to surface things nobody has ever seen. Triage each item into a reviewed allow-list (with a reason) or a fix. A permanently red drift job trains people to ignore it. HCP Terraform offers the same thing as “health assessments” if you’re on that platform.
Apply-coverage reconciler. Compare commits on main that touched Terraform paths against commits for which an apply run actually exists. Anything in the first list and not the second is a silent gap.
This exists because GitHub sometimes doesn’t create a workflow run for a push at all. One team saw a merge that matched every path filter produce zero runs, not even queued ones, while unrelated PR checks ran normally. That merge rotated API keys for an RPC proxy. Its GitOps half (the callers’ new key) synced within minutes. Its Terraform half (the proxy config that made the new key valid) never applied. Callers presented a key the proxy didn’t accept, an indexer crash-looped on 401, and a dependent service went down behind it. With per-commit change detection, the next merge didn’t pick up the skipped change either.
Two lessons:
- Add
workflow_dispatchto every apply workflow. Without it, recovering a dropped push needs a fresh commit. - Have the reconciler report, not apply. A missing apply usually needs a human decision (re-run, revert, or a follow-up such as a pod restart for a config map with no checksum annotation).
Add cheap, related checks:
- lockfile-unchanged in plan jobs
- “prod image tag exists in the registry”
- ratcheted policy checks (Checkov, conftest) over both
.tfand rendered Kubernetes manifests
4.9 Separate pipelines for observability config: the “skipped looks green” trap
Teams often split Grafana or PagerDuty Terraform into its own workflow pair. Different providers need different network paths (Grafana behind a VPN, for example), and alert-rule churn shouldn’t queue behind a 20-minute EKS apply. That’s sensible, with two traps:
- Skipped jobs look green. The main apply workflow’s matrix is an allow-list of root names. A change under
terraform/grafana/**triggers it, every matrix job is filtered out, and the run completes as success, which reads exactly like “applied”. The real apply is a different, later workflow run. Teach the verification habit (“find the run named Apply — Grafana, then confirm the rule through the provisioning API”) and encode it in a post-merge verification script. Better still, make the main workflow’s path filter exclude roots it doesn’t own, so it doesn’t run at all. - Cross-pipeline ordering. Because the observability workflow is faster, an alert rule merged together with the scrape job that feeds it goes live before its metric exists. With
no_data_state = Alerting, that’s an immediate false page. One rollout paged at top severity for nine minutes with an empty label set. Rule: scrape job first, verifyup{job=...}returns one series per target reading 1, then merge the rule. And preferno_data_state = OKonup-based rules, since an absentupmeans a missing scrape job, not an outage.
The general lesson: when one merge feeds two pipelines that apply at different times, neither the plan nor the rendered diff shows the transition state. Both describe the end state. Moving a value between two GitOps values files can leave a key in neither file for the minutes between ArgoCD reading git at HEAD and Terraform updating the Application’s valueFiles list. One team took a service down for six minutes that way when a required env var disappeared. Order these as multi-merge changes, and where possible render the transition state in CI and fail the PR.
4.10 Dispatch bypasses, injection and org-wide App tokens
Every workflow_dispatch path to production is a gate bypass until proven otherwise. Typical finds:
- A promotion workflow that accepts
env=prod(withhotfix/is_rollback) on dispatch. It enters the prod environment, which has only a branch policy, and never passes through the issue gate that the normal CD pipeline uses. - A service repository whose release workflow hard-codes
DEPLOYMENT_ENV=prodon every push to main and on dispatch from any branch. - Service repositories that mint GitHub App installation tokens without
repositories:. Withcreate-github-app-token, settingownerwithoutrepositoriesscopes the token to every repository in the installation [20]. Write access to any service repo is then enough to dispatch into the infra repo. If the same App can admin-merge, it can push to inframainas well.
Fixes:
- Route every production path through the one approval job. Make the promotion workflow reject
env=produnless it’s called from the approved pipeline (checkgithub.workflow_refor a signed input). - Scope every App token with
repositories:, and the minimumpermission-*inputs. - Use a separate App for infra admin-merge, installed only on the infra repo.
Script injection. ${{ inputs.* }} or ${{ github.event.* }} interpolated into run: is evaluated before the shell runs, so its content becomes code. GitHub’s guidance is to pass untrusted values through an intermediate environment variable [21], and Scorecard’s Dangerous-Workflow check flags the pattern [17]. One platform had it in three workflows, one of which also spliced inputs into github-script JavaScript strings. The same sink was broken for normal use too: echo "matrix=${{ inputs.environments }}" let the shell strip the JSON quotes, fromJson failed, and a manual plan “succeeded” with zero jobs. Moving the input to env: fixes both problems.
pull_request_target and workflow_run with checkout of PR code grant secrets and a write token to untrusted code. GitHub Security Lab’s “pwn request” guidance is required reading [22]. Infrastructure repositories almost never need either trigger.
Hygiene that compounds:
- top-level
permissions: contents: read, with escalation per job [21] - every third-party action pinned by SHA, enforced by a linter
- checksums on every downloaded tool (kubectl, yq, the SSM plugin) in jobs that hold deploy credentials
- first-party reusable workflows called by SHA or tag, not
@main. A push to a shared workflows repo otherwise changes build, sign and scan for every service at once
5. Failure modes and field lessons
These are generalized from real incidents and review findings. Each one cost someone an outage, a page or a near-miss.
- A reviewed merge executed someone else’s pending destroys. The apply re-planned the whole root, and an earlier failed apply had left IAM deletions pending. Defense: the unresolved-failure check (§4.6), approval issues that warn about pending changes, saved plans for gated roots.
- The push that never ran. GitHub created no workflow run for a merge. Half of a credential rotation shipped (GitOps), the other half (Terraform) never did, and downstream services crash-looped on 401. Defense: a coverage reconciler,
workflow_dispatchon every apply workflow, change detection relative to the last successful deploy. - Pending runs cancelled by the concurrency default. Three quick merges, and the middle apply silently replaced. Defense:
queue: max, or detection relative to the last deployment, plus the reconciler. - The exemption that keyed on paths. It would have exempted a prod env-var edit. Defense: diff-content classification with allow-listed keys (§4.5).
- The exemption that keyed on the actor. The bot’s identity was trusted instead of its output. Defense: inspect content regardless of who merged.
- Forged approval through line pairing. A multi-line comment shifted the author/body alignment. Defense: structured JSON parsing and exact-match bodies, with tests.
- The approval gate that was never configured. A required-reviewers API call returned 422 on a Team plan, and the ticket was closed anyway. Defense: test gates adversarially, and re-verify “Done” security tickets with
grepand a live probe. - “Plan is read-only.” PR plan jobs could read every secret in the account, including a funded wallet’s key, through broad
GetSecretValueand the state CMK. Defense: split plan roles, named secret ARNs, sweep and delete orphaned wallet credentials immediately when a feature is retired. - Destruction guardrails, misuse ignored. Deny on KMS deletion, no Deny on
kms:Sign, and an un-gated dev role could sign with a production key. Defense: deny use of signing keys to all CI roles, plus principal allow-lists in key policies. - The green run that applied nothing. An observability root changed, the main workflow skipped every job, and the run showed success. Defense: path-filter exclusion, and verification through the target API.
- An alert rule ahead of its metric. Separate pipelines applied at different speeds, and NoData paged at top severity. Defense: two merges, with a verification step between them.
- The allow-list that failed open. A new production-tier root was added to the apply order but not to the gated set. Defense: a CI test asserting that every root matching
prod*|shared*is gated. - A bootstrap that broke its own PR. The workflow switched to
role-to-assumebefore the roles existed, so the PR’s own plan failed. Defense: apply the identity layer from a human session first. - Admin in IAM, 401 in the cluster. EKS access entries were missing for a CI role that only read the management cluster. Defense: one access-entry inventory, reviewed together with IAM role changes.
- The dispatch side door. A promotion workflow accepted
env=prodon dispatch, and org-wide App tokens let any service repo trigger it. Defense: one approval path, scoped tokens. - Silent dispatch breakage. A JSON input interpolated into shell lost its quotes, and a manual plan ran zero jobs while reporting no error. Defense: pass inputs through
env:, and assert that the matrix is non-empty. - Prod gate approved by the person who wrote the change. This is expected on a small team, and it needs to be said out loud. Defense: narrow code owners on sensitive paths, required code-owner review, a scheduled audit of approver vs author.
6. Best-practice checklist
Day 1 (seed, before the first production key exists)
- OIDC federation only. No static AWS keys in any CI secret store.
-
Every trust policy uses
StringEqualsonsub, with norepo:org/repo:*. -
One deploy role per Terraform root, trusted on
environment:<root>. Every such environment has amain-only deployment branch policy, created before the first workflow references it. -
The PR plan role is trusted on
pull_requestonly. It has no access to secrets of signing or wallet material, and it isn’t the role used for applies. -
Guardrail Deny on every deploy role: IAM users/keys, CI-role and OIDC-provider tampering,
sts:AssumeRole, KMS deletion/policy writes outside owned keys,kms:Sign/Decrypton signing keys, CloudTrail/Config teardown. -
Top-level
permissions: contents: read. Actions pinned by SHA. Nopull_request_target. -
No
${{ inputs.* }}or${{ github.event.* }}insiderun:. -
Per-root concurrency groups with
cancel-in-progress: false.max-parallel: 1in dependency order. -
workflow_dispatchon every apply workflow, for recovery.
First year (Series A)
- A production approval gate: native required reviewers if you’re on Enterprise, otherwise an issue-comment gate that fails closed, parses structured comments and gates its own bypass.
- Image promotions exempted by a tested diff-content classifier, with asymmetric handling of safety keys.
- A “verify no unresolved apply failure” check on every unattended gated apply, with a written recovery runbook.
-
Split plan roles:
plan-prwithout secrets,plan-mainwith named secret ARNs. PR-plan secrets scoped per matrix leg. -
A daily
refresh: truedrift plan with a triaged allow-list, plus an apply-coverage reconciler. -
Code-owner review required on
.github/, IAM/OIDC and signing-key files, from a short owner list. -
Every production dispatch path routed through the approval job. All GitHub App tokens scoped with
repositories:. - Observability Terraform in its own pipeline, and excluded from the main workflow’s path filter. Post-merge verification through the provider’s API.
-
Policy checks on rendered Kubernetes manifests, not just
.tf.
At scale
- AWS Organizations with prod, non-prod, shared-tooling, security and log-archive accounts. SCPs replace most of the Deny list [9][13].
-
Least-privilege allow-lists per root, generated from CloudTrail with IAM Access Analyzer [13]. Permissions boundaries required on
iam:CreateRole[8]. - Saved-plan applies for production roots, with protected plan artifacts [11].
- Separation of duties: approver ≠ author enforced by the platform (Enterprise “prevent self-review”, or an external approval service) [15][16].
- Build provenance and signature verification at deploy time, in line with SLSA Build L2–L3 [23].
7. Maturity roadmap
| Stage | Identity | Plan | Apply | Gates | Detection |
|---|---|---|---|---|---|
| 0: Ad hoc | Static keys, admin | Local plans | Laptop applies | None | None |
| 1: Federated | OIDC, one admin role | PR plan with the same role | Merge-to-apply, unattended | Branch protection | None |
| 2: Scoped | Per-root deploy roles on environment:; Deny guardrails | Plan role read-only, secrets broad | Serialized matrix; per-root concurrency | Issue-comment gate on prod; fail-closed | Daily refresh plan |
| 3: Hardened | Split plan roles; signing-key Deny; scoped App tokens | No secrets on PR; named ARNs on main | Ungated tier first; unresolved-failure check; dispatch recovery | Diff-content promotion exemption; one prod path; CODEOWNERS on sensitive paths | Coverage reconciler; rendered-manifest policy |
| 4: Structural | Multi-account, SCPs, permissions boundaries, least-privilege allow-lists | PR plans against non-prod only | Saved-plan applies | Platform-enforced separation of duties | Provenance verification; continuous IAM Access Analyzer |
Most startups can reach stage 3 in one or two focused quarters without buying anything. Stage 4 needs the multi-account migration, and for a company holding fund-moving keys, that migration should start before the first institutional partner asks about it.
8. References
- OWASP, Top 10 CI/CD Security Risks. https://owasp.org/www-project-top-10-ci-cd-security-risks/
- NIST SP 800-204D, Strategies for the Integration of Software Supply Chain Security in DevSecOps CI/CD Pipelines (2024). https://csrc.nist.gov/pubs/sp/800/204/d/final
- GitHub Docs, OpenID Connect reference (subject claim formats, immutable subjects). https://docs.github.com/en/actions/reference/security/oidc
- AWS IAM User Guide, Create a role for OpenID Connect federation, section “Configuring a role for GitHub OIDC identity provider”. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html
- AWS IAM User Guide, Policy evaluation logic. https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html
- GitHub Docs, Configuring OpenID Connect in Amazon Web Services. https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
- AWS IAM User Guide, Permissions for AssumeRole, AssumeRoleWithSAML, and AssumeRoleWithWebIdentity. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_assumerole.html
- AWS IAM User Guide, Permissions boundaries for IAM entities. https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html
- AWS Organizations User Guide, Service control policies (SCPs). https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html
- HashiCorp, HCP Terraform security model. https://developer.hashicorp.com/terraform/cloud-docs/architectural-details/security-model
- HashiCorp, Running Terraform in automation. https://developer.hashicorp.com/terraform/tutorials/automation/automate-terraform
- HashiCorp, terraform plan command reference (
-refresh=false,-out). https://developer.hashicorp.com/terraform/cli/commands/plan - AWS Well-Architected Framework, Security Pillar, SEC03-BP02 Grant least privilege access. https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_permissions_least_privileges.html
- GitHub Docs, Deployments and environments (plan availability of protection rules). https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments
- GitHub Docs, Managing environments for deployment (required reviewers, prevent self-review). https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment
- NIST SP 800-53 Rev. 5, Security and Privacy Controls for Information Systems and Organizations (AC-5 Separation of Duties, CM-3 Configuration Change Control). https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final
- OpenSSF Scorecard, Checks documentation (Dangerous-Workflow, Token-Permissions, Branch-Protection, Code-Review, Pinned-Dependencies). https://github.com/ossf/scorecard/blob/main/docs/checks.md
- DORA, Capabilities: Streamlining change approval. https://dora.dev/capabilities/streamlining-change-approval/
- GitHub Docs, Control the concurrency of workflows and jobs. https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency
- GitHub, actions/create-github-app-token (owner/repositories scoping). https://github.com/actions/create-github-app-token
- GitHub Docs, Secure use reference (script injection,
GITHUB_TOKENpermissions). https://docs.github.com/en/actions/reference/security/secure-use - GitHub Security Lab, Keeping your GitHub Actions and workflows secure: Preventing pwn requests. https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
- SLSA, Security levels and requirements v1.0. https://slsa.dev/spec/v1.0/requirements
- Google, Site Reliability Engineering, Chapter 8 “Release Engineering” (access control over who may approve, release and deploy). https://sre.google/sre-book/release-engineering/