Supplementary detail for SKILL.md. Read the manifest first for the operational overview.
This reference is a worked example of BC Gov's GitHub Actions hardening: annotated workflows you can copy, a permissions: lookup table, a pinning decision matrix, the branch-protection settings the workflows assume, and a threat model mapping each rule back to the attack it defends against.
All examples use bcgov/agent-skills as the worked repo; substitute your own paths and package names.
A pull_request workflow that lints, tests, validates artefacts, and exposes a single required status check.
name: PR
# Runs on every pull request.
on:
pull_request:
# Workflow-scope default: deny everything. Each job opts in only to the scopes it needs.
permissions: {}
jobs:
# 0. Hard gate: fail loudly on PRs opened from a fork. Produces a real failed
# status check (not a neutral "skipped") so branch protection sees it.
fork-gate:
runs-on: ubuntu-24.04
# No checkout, no API calls — inherits the workflow's deny-all permissions.
steps:
- name: Reject PRs from forks
env:
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
echo "PR opened from fork ($HEAD_REPO); re-land it from a branch in $BASE_REPO."
exit 1
fi
# 1. Lint + tests. Gated on fork-gate. Needs to read the repo.
lint-tests:
needs: fork-gate
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v6 # first-party → major tag is fine
with:
fetch-depth: 0 # full history to diff against base branch
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.19"
enable-cache: true
- name: Check formatting
run: uv run ruff format --check .
- name: Lint
run: uv run ruff check .
- name: Tests
run: uv run pytest -q
# 2. Domain validation. Gated on lint-tests.
skills:
needs: lint-tests
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.19"
enable-cache: true
- name: Validate changed skills
run: uv run python scripts/validate_skill.py --base "origin/${{ github.base_ref }}"
# 3. Aggregator. The SINGLE required status check on `main`.
results:
if: always()
needs: [fork-gate, lint-tests, skills]
runs-on: ubuntu-24.04
# No permissions needed — inherits the workflow's deny-all default.
steps:
- name: Fail if any required job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
set -euo pipefail
echo "At least one required job failed or was cancelled."
echo '${{ toJSON(needs) }}'
exit 1
- name: All required jobs passed
run: echo "All required jobs passed (or were intentionally skipped)."Why this shape:
- Workflow-scope
permissions: {}is the BC Gov default — deny everything, then jobs that need access (here,lint-testsandskillsneedcontents: readto check out) opt in explicitly. - The
resultsjob is the only check named in branch protection. Renaminglint-teststolintor splittingskillsinto two jobs never touches the ruleset. if: always()ensuresresultsruns even when an upstream job fails — without it, branch protection would see the required check asskippedand the merge would be blocked indefinitely.skippedis treated as a pass on purpose so conditional jobs (e.g. one withif: contains(github.event.head_commit.message, 'docs')) don't false-fail the gate.- Inputs from
github.event.pull_request.head.repo.full_namego throughenv:and shell-quoted"$HEAD_REPO", so a forked repo with a payload in its name can't escape the comparison. set -euo pipefailat the top of every multi-linerun:block makes failures loud: an unset variable is a hard error, not an empty string; a failing left-hand side of a pipe propagates instead of being masked by a zero exit on the right.
A worked example of a workflow that publishes to GitHub Packages npm on a main push, but only when files under skills/** actually change, and skips any version already on the registry. (This is a generic teaching example; this repo doesn't actually publish to a registry.)
name: Publish
on:
push:
branches: [main]
paths:
- "skills/**"
- ".github/workflows/publish.yml"
- "scripts/publish-changed-packages.sh"
# Workflow scope: deny-all. The publish job opts in to the narrow scopes it needs.
permissions: {}
concurrency:
group: publish
cancel-in-progress: false # never cancel a mid-flight publish
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # only escalation: needed for `npm publish` to GitHub Packages npm (npm.pkg.github.com)
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # for the version-skip diff
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://npm.pkg.github.com"
scope: "@example"
- name: Publish changed packages (version-skip if already on registry)
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/publish-changed-packages.shWhy this shape:
paths:filter means the workflow doesn't burn minutes when the change is docs-only or workflow-only-unrelated.permissions: {}at workflow scope means no token capabilities by default — the publish job explicitly opts in tocontents: read(for checkout) andpackages: write(fornpm publish). Any helper job added later starts at deny-all and has to opt in explicitly.NODE_AUTH_TOKENuses the built-inGITHUB_TOKEN, not a PAT. The token lives only for the workflow's duration and respects the job'spermissions:ceiling.concurrency: { group: publish, cancel-in-progress: false }prevents two pushes from racing each other to publish the same version (npm publishis not idempotent for in-flight tarballs).- The publish script's job is to be idempotent — it inspects each skill's
package.jsonversion, queries the registry, and skips thenpm publishif that exact version already exists. A merge only ships what was actually bumped. - The script itself starts with
set -euo pipefailso a single failed publish stops the loop instead of marching on and reporting success.
Deploys a static site from docs/ on every push to main. Uses GitHub's official Pages OIDC flow (no PAT, no Pages deploy key).
name: Deploy GitHub Pages
on:
push:
branches: [main]
paths:
- "docs/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write # for OIDC to Pages
concurrency:
group: pages
cancel-in-progress: false # don't cancel a publish in flight
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Build documentation
run: |
set -euo pipefail
cd docs
chmod +x build.sh
./build.sh
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: docs
deploy:
needs: build
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5Why this shape:
- Build and deploy split so the deploy job has the narrow surface area Pages OIDC expects (
id-token: writeonly needs to be claimed by a job that actually exchanges it). environment: github-pagesis what binds the deploy to the repo's Settings → Pages config.- The whole pipeline is keyless: no PAT, no deploy key, no
GH_TOKEN: ….
Two files. Together they bump every action / runtime and auto-merge low-risk PRs without a maintainer.
.github/dependabot.yml:
version: 2
updates:
# GitHub Actions used by the workflows in .github/workflows. We pin
# third-party actions to a commit SHA with the version in a comment;
# Dependabot understands that pattern and will keep bumping the SHA.
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
open-pull-requests-limit: 5
commit-message:
prefix: "deps(actions)"
groups:
# Roll every github-actions bump into a single PR per run so we review
# and merge them as one batch instead of N parallel PRs.
github-actions:
patterns:
- "*"
# Python tooling (validator + tests). Declared in pyproject.toml and locked
# in uv.lock. Use the dedicated `uv` ecosystem (not `pip`) so Dependabot
# bumps both the manifest *and* the lockfile in the same PR — the `pip`
# ecosystem ignores uv.lock and would leave it drifting behind.
- package-ecosystem: uv
directory: /
schedule:
interval: daily
open-pull-requests-limit: 5
commit-message:
prefix: "deps(uv)"
groups:
python:
patterns:
- "*"Swap in (or add) other ecosystems your repo actually uses —
npm,pip,docker,gradle,bundler, etc. Keep the same shape:package-ecosystem+directory+schedule+commit-message.prefix+ a single all-grouped batch so each Dependabot run is one reviewable PR per ecosystem.
.github/workflows/dependabot-auto-merge.yml:
name: Dependabot Auto Merge
on:
pull_request_target: # required: pull_request from Dependabot has read-only token
types: [opened, reopened, synchronize]
# Deny-all baseline at the workflow scope. The single job below opts in to
# the narrower set of write tokens it actually needs, so any other job added
# later cannot accidentally inherit broader permissions.
permissions: {}
concurrency:
group: dependabot-auto-merge-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
auto-approve-and-merge:
if: github.event.pull_request.user.login == 'dependabot[bot]' # STRICT actor gate
runs-on: ubuntu-24.04
# Only this job needs write access: gh pr review needs pull-requests:write,
# gh pr merge --auto needs contents:write to enable the auto-merge label.
permissions:
contents: write
pull-requests: write
steps:
- name: Approve Dependabot PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
gh pr review --approve "$PR_URL"
- name: Enable auto-merge for Dependabot PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
gh pr merge --auto --squash --delete-branch "$PR_URL"Why this shape — and why it's safe:
pull_request_targetis the only trigger that lets a Dependabot PR mutate the repo (PR token is read-only onpull_request). It runs in the base repo's context with the base repo's secrets, which is why it's normally dangerous.- The
if:actor gate makes it safe: the job body only runs when the PR author is literallydependabot[bot]. Any other PR opener (including a fork attacker) sees the job skip. - The workflow never
actions/checkouts the PR head SHA. It callsghagainst PR metadata only — no PR code is ever executed in the privileged context. concurrencycancels in-flight runs when Dependabot pushes a force-update to the same PR (which is common as the auto-merge waits for status checks).- Repo-side: enable Settings → General → Pull Requests → Allow auto-merge. Without it,
gh pr merge --autoerrors.
Start with permissions: {} (deny-all) at workflow scope, or contents: read if every job legitimately needs to read the repo. Add scopes only on the specific job that needs them.
| Scope | What it grants | Add when … |
|---|---|---|
permissions: {} |
Nothing — deny-all baseline | Workflow scope, every workflow, as the BC Gov default starting point. |
contents: read |
Read repo files, history, releases | Any job that runs actions/checkout or reads release metadata. |
contents: write |
Push commits, create tags, create releases, write release assets | Auto-merge, release-please, git push from CI. |
pull-requests: write |
Open/edit PRs, post review approvals, set labels | Bot-comment workflows, auto-approve, auto-merge. |
issues: write |
Open/edit issues, comment, label | Triage bots, "stale" actions. |
packages: write |
npm publish to GitHub Packages npm (npm.pkg.github.com); push container images to GHCR (ghcr.io) |
The publish job only. |
packages: read |
npm install private packages from GitHub Packages npm (npm.pkg.github.com) |
Any job that consumes @your-org/* packages. |
pages: write |
Deploy to GitHub Pages via the official actions | Pages deploy job only. |
id-token: write |
Mint an OIDC token for federation (Pages, AWS, Azure, sigstore) | OIDC-based deploys. |
actions: read |
Read other workflows' run metadata | "Required workflows" reporting, run-skipping logic. |
actions: write |
Cancel runs, dispatch workflows | Avoid; usually not needed. |
checks: write |
Create check runs (richer than status checks) | Custom CI reporters; most jobs don't need this. |
security-events: write |
Upload SARIF (CodeQL, Snyk, Trivy) | Scanner jobs only. |
Anything you don't list is implicitly denied when you supply any permissions: block. That's the point: explicit allow-list, no inheritance from the repo default.
| Class | Pin format | Example |
|---|---|---|
First-party (actions/*) |
Major tag | uses: actions/checkout@v6 |
First-party (github/*) |
Major tag | uses: github/codeql-action/init@v3 |
| Third-party with stable release cadence | Commit SHA + version comment | uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 |
| Third-party with only tags (no SHA pin) | Fork to BC Gov org, pin fork by SHA | uses: bcgov/forked-action@<sha> # forked from upstream@1.2.3 |
Container actions (docker://) |
Image digest | uses: docker://ghcr.io/owner/img@sha256:abc... |
Local actions (./) |
Path only | uses: ./.github/actions/internal-helper |
Reusable workflows (org/repo/.../wf@ref) |
Same rule as the action: major tag or SHA | uses: bcgov/shared-workflows/.github/workflows/lint.yml@v2 |
Dependabot understands both styles. If you SHA-pin without the # v8.2.0 comment, Dependabot still works but the diff is unreviewable — keep the comment.
What the BC Gov default ruleset for main should contain (settable via Settings → Rules → Rulesets or the legacy Settings → Branches → Branch protection rules):
- Require a pull request before merging. Approvals: 0 by default; raise per repo policy. Dismiss stale approvals on push.
- Require status checks to pass. Strict (PR must be up to date with base). Required check: the single
resultsjob from your PR workflow. Nothing else. - Require signed commits. Enforced for everyone — admins included if the repo policy says so.
- Block force pushes.
- Block branch deletion.
- Restrict who can push directly to
main. No one, ideally — even admins go through PR. - Require linear history if your release flow expects it (squash-or-rebase merges). Skip if you rely on merge commits.
- Lock branch. Off —
mainaccepts merges. - CodeQL or similar code scanning as an additional required check on repos that have it.
Mirror this same shape across BC Gov repos so contributors get a consistent experience.
| Attack | Defence |
|---|---|
| Action tag hijack — attacker compromises an upstream action's release tag and ships an exfil payload | SHA-pin third-party actions; let Dependabot bump SHAs through review. |
Fork PR token exfil — fork PR's workflow run reads secrets.* from the base repo |
fork-gate job; secrets are not passed to fork-PR runs by default. |
| Script injection via PR title / branch name / issue body | Pass attacker input through env:, quote "$VAR" in shell. |
pull_request_target abuse — fork PR triggers a privileged workflow that runs the PR's code |
Never checkout + execute the PR head SHA in pull_request_target. Actor-gate the job. |
| PAT sprawl / orphan PATs — a long-lived PAT outlives its owner / its purpose | Use the built-in GITHUB_TOKEN or OIDC; never paste a PAT into a secret. |
| Required check rename — branch protection no longer recognises the check and silently auto-approves | Single results aggregator as the only required check; rename freely. |
Workflow added to main that grants itself write-all |
Repo default Workflow permissions set to read; workflows must opt in. |
A *-latest runner alias (ubuntu-latest / windows-latest / macos-latest) rolls a new major version overnight and breaks tooling — Xcode majors and SDK churn on macOS, Visual Studio component updates on Windows, glibc / Python defaults on Ubuntu |
Pin to a specific version label (ubuntu-24.04, windows-2025 / windows-2022, macos-26 / macos-15); upgrades become a deliberate, reviewable PR. |
Force-push to main rewrites history during an incident |
Branch ruleset blocks force-push and deletion. |
Unsigned commit lands on main and bypasses provenance |
Branch ruleset Require signed commits (cannot be temporarily disabled in practice). |
| In-flight publish cancelled by a newer push | concurrency: { group: publish, cancel-in-progress: false }. |
Multi-line run: block silently swallows a failure on the left of a pipe |
set -euo pipefail as the first line of every non-trivial run: block. |
Missing secret silently becomes an empty string and bricks a gh call as a no-op success |
set -u (part of -euo pipefail) makes the unset variable a hard fail. |
Long-lived cloud-credential exfil — AZURE_CLIENT_SECRET or AWS access key in secrets.* is stolen via tag hijack, pull_request_target slip, or insider |
OIDC federation: id-token: write on the deploy job, official provider action SHA-pinned per §6 (azure/login@<sha> # v3.0.0, aws-actions/configure-aws-credentials@<sha> # v6), and a cloud-side federated-credential subject that pins repo + environment / ref. No *_SECRET / *_KEY in secrets.*. |
| Message | Cause | Fix |
|---|---|---|
Resource not accessible by integration |
The job's permissions: block doesn't include the scope the API call needs |
Add the narrow scope on the job, not the workflow. |
Refusing to allow a Personal Access Token to create workflow |
A PAT is trying to push a .github/workflows/ change |
Use GITHUB_TOKEN (which can) or a fine-grained PAT with explicit "Workflows" permission. Better: stop pushing workflow files from CI. |
npm ERR! 401 Unauthorized on publish |
NODE_AUTH_TOKEN not set, or permissions: { packages: write } missing on the job |
Set env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} on the publish step and grant packages: write on the job. |
npm ERR! 403 Forbidden — You cannot publish over the previously published versions |
A version that's already on the registry is being re-published | Bump the version in the changed skill's package.json (the publish script's version-skip should normally prevent this; investigate the diff). |
Required check name appears as Expected — Waiting for status forever |
The required check's name was renamed in the workflow file but not in branch protection | Switch to a results aggregator as the single required check, then renames are free. |
Auto-merge is not allowed for this repository |
Repo setting Allow auto-merge is off | Settings → General → Pull Requests → Allow auto-merge. |
| Dependabot PR opens but no auto-merge workflow runs | The workflow uses pull_request instead of pull_request_target — Dependabot's PR token is read-only |
Switch the trigger to pull_request_target and keep the strict actor gate. |
Error: The process '/usr/bin/git' failed with exit code 128 |
Checkout couldn't fetch enough history for a diff-based step | Add with: { fetch-depth: 0 } to actions/checkout. |
| Workflow runs on every push even when nothing it cares about changed | Missing paths: filter |
Add paths: (or paths-ignore:) to scope the trigger. |
pull_request_target workflow exposed a secret to a fork PR |
Job didn't gate on actor and used the elevated token | Add if: github.event.pull_request.user.login == '<expected-actor>' and never checkout the PR head SHA. |
AADSTS70021: No matching federated identity record found (Azure) / Not authorized to perform sts:AssumeRoleWithWebIdentity (AWS) |
Cloud-side federated credential's subject claim doesn't match the workflow's actual sub (branch / env / PR mismatch) |
Update the Federated Identity Credential (Entra) or IAM OIDC trust to match the runtime sub — typically repo:OWNER/REPO:environment:NAME, repo:OWNER/REPO:ref:refs/heads/main, or repo:OWNER/REPO:pull_request. Don't add a client-secret / access key to "unblock". |
Before opening the PR:
# YAML lint (the repo's .yamllint allows `on:` as a truthy key)
yamllint .github/workflows/
# Shellcheck any non-trivial run: blocks (extract them or use actionlint)
# https://github.com/rhysd/actionlint pulls them out for you.
actionlint
# Full repo gate (matches what `results` will run on the PR)
make lint test validateIf actionlint is unavailable, the GitHub Marketplace has a Docker variant that runs the same checks in 2–3s.
The BC Gov standard is Conventional Commits with the scope derived from the primary directory touched. For GHA work that maps cleanly to a small vocabulary — keep PR titles aligned with what Dependabot is already emitting so changelog tooling stays consistent.
| What changed | Type + scope | Example |
|---|---|---|
Edit a workflow file (.github/workflows/<x>.yml) |
ci(workflows) |
ci(workflows): add concurrency group to publish |
Add a helper script under .github/scripts/ |
ci(scripts) |
ci(scripts): make publish-changed-packages.sh idempotent |
| Bump a pinned action SHA / tag | deps(actions) |
deps(actions): bump astral-sh/setup-uv to v8.3.0 |
Bump a runtime in a workflow (node-version, Python, etc.) |
ci(workflows) (or deps(<ecosystem>)) |
ci(workflows): pin Node to 24 |
Bump npm / pip deps used by a workflow |
deps(npm) / deps(pip) |
deps(npm): bump @octokit/core to 6.x |
| Tweak Dependabot config | ci(dependabot) |
ci(dependabot): group minor + patch updates |
| Edit branch protection / ruleset config in code | chore(rules) or ci(ruleset) |
ci(ruleset): require strict status checks on main |
Dependabot in this repo already emits deps(actions), deps(npm), deps(pip) via commit-message.prefix — see .github/dependabot.yml. Human PRs should follow the same shape.
The BC Gov default for any workflow that deploys to a public cloud is OIDC federation — no long-lived AZURE_CLIENT_SECRET or AWS access key in secrets.*. The deploy job claims id-token: write, the official provider action exchanges the short-lived GitHub OIDC token for a cloud credential, and the cloud-side trust pins the GitHub repo + ref so a fork or a non-main branch cannot exchange it.
jobs:
deploy:
runs-on: ubuntu-24.04
environment: prod # ties this run to the federated cred's environment subject
permissions:
contents: read
id-token: write # mint the OIDC token; granted on the deploy job ONLY
steps:
- uses: actions/checkout@v6
- name: Azure CLI login (OIDC)
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} # identifier, not a credential
tenant-id: ${{ secrets.AZURE_TENANT_ID }} # identifier, not a credential
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} # identifier, not a credential
# NO client-secret. If you ever add one, you've defeated the point of OIDC.
- run: az account showThe three secrets.* values above are stored in repo / org / environment secrets purely as configuration — they're just GUIDs and an Entra app registration ID. The actual trust is established by a Federated Identity Credential on the Entra app (or User-Assigned Managed Identity), whose subject claim pins exactly which workflow can mint a token for it.
azure/login is owned by Microsoft, not GitHub (actions/* / github/*), so it falls under §6's "third-party with stable release cadence" rule — SHA-pin with a # v3.0.0 (or # v3) comment so Dependabot can still propose bumps as reviewable diffs.
Structured as a reusable workflow (on: workflow_call) so inputs.environment_name resolves — the same shape works under workflow_dispatch with an input of the same name.
on:
workflow_call:
inputs:
environment_name:
type: string
required: true
env:
AWS_REGION: ca-central-1 # single source of region for every aws-* step in this job
jobs:
deploy:
runs-on: ubuntu-24.04
environment: ${{ inputs.environment_name }} # binds the run (and its OIDC sub) to the named env
permissions:
contents: read
id-token: write # mint the OIDC token; granted on the deploy job ONLY
steps:
- uses: actions/checkout@v6
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
role-session-name: ${{ inputs.environment_name }}-deployment
aws-region: ${{ env.AWS_REGION }}
# NO aws-access-key-id / aws-secret-access-key. AssumeRoleWithWebIdentity does the rest.
- run: aws sts get-caller-identityWhy this shape:
aws-actions/*is AWS-owned, not first-partyactions/*orgithub/*, so it falls under "third-party with stable release cadence" in §6 — SHA-pin with a# v6(or full# v6.2.0) comment so Dependabot can still propose bumps as reviewable diffs.role-session-name: ${{ inputs.environment_name }}-deploymentshows up in CloudTrail as the assumed-role session identifier — a per-environment name (prod-deployment,test-deployment) makes "who ran what against which account?" answerable from CloudTrail alone, without cross-referencing GitHub run IDs.secrets.AWS_DEPLOY_ROLE_ARNkeeps the role ARN out of the workflow file. ARNs aren't sensitive —vars.AWS_DEPLOY_ROLE_ARNworks too — but parameterising lets the same reusable workflow be called from multiple repos / environments, each binding to a different IAM role.env.AWS_REGIONat job scope means subsequentaws …CLI steps inherit the region automatically — no risk of one step targetingca-central-1and the next defaulting tous-east-1.environment: ${{ inputs.environment_name }}binds the run to the named GitHub Environment, so the OIDC token'ssubclaim becomesrepo:OWNER/REPO:environment:<environment_name>— exactly the strongest subject shape from the cookbook below, and it composes with the Environment's required-reviewers gate.
IAM side: create an OIDC identity provider pointing at https://token.actions.githubusercontent.com and one IAM role per environment (AWS_DEPLOY_ROLE_ARN), each whose trust policy pins token.actions.githubusercontent.com:sub to its own repo:OWNER/REPO:environment:<env> subject — so the prod role can only be assumed from runs bound to the prod Environment, and so on.
The cloud-side trust pins the workflow by a subject claim that must match the GitHub OIDC token's sub field exactly. Pick the most restrictive shape that fits the deploy:
| Use case | Subject claim |
|---|---|
| Deploy from a specific GitHub environment | repo:OWNER/REPO:environment:prod |
| Deploy from a specific branch | repo:OWNER/REPO:ref:refs/heads/main |
| Deploy from a specific tag (pattern) | repo:OWNER/REPO:ref:refs/tags/v* |
| Run from a PR (rarely needed; usually never grant) | repo:OWNER/REPO:pull_request |
Prefer the environment: shape — it composes with GitHub's Settings → Environments → Required reviewers / Wait timer / Deployment branches gates, so the cloud-side trust automatically inherits any human-approval gate you configure. Without environment:, any push to the matching ref: can mint a token.
The strongest BC Gov default is a deploy job with both environment: prod and a federated credential whose subject is repo:OWNER/REPO:environment:prod, plus Required Reviewers on the prod environment so the OIDC token is only mintable after human approval. That makes the entire deploy path keyless and human-gated, with no long-lived cloud secret anywhere in the system.