| name | github-actions | ||||||
|---|---|---|---|---|---|---|---|
| description | BC Gov hardening patterns for GitHub Actions workflows — deny-all `permissions: {}` baseline with per-job step-ups, first-party tag vs third-party SHA pinning, fork-gate, single results-aggregator required check, pinned ubuntu-24.04 runners, script-injection-safe env handling, and signed-commit ruleset interaction. Covers `.github/workflows/*.yml`, `dependabot.yml`, and the branch-protection / repository-ruleset settings that pair with them. Use when authoring or reviewing a workflow in a BC Gov repo, debugging permission-denied errors, or onboarding Dependabot. | ||||||
| owner | bcgov | ||||||
| tags |
|
- Authoring or reviewing any file under
.github/workflows/in a BC Gov repo. - Wiring branch protection / a repository ruleset and need to pick the required status check(s).
- Hardening a public repo's pipeline against fork-PR token exfiltration, action tag hijacks, or script injection from PR titles / branch names / issue bodies.
- Debugging
Resource not accessible by integration,Refusing to allow a Personal Access Token to create workflow, or otherGITHUB_TOKENpermission errors. - Adding or auditing Dependabot — both
.github/dependabot.ymland an auto-merge workflow for low-risk bumps. - Picking a runner OS / version, action pin format (tag vs SHA), or
permissions:scope set for a new job. - Publishing artifacts, npm packages, container images, or GitHub Pages from a workflow and need OIDC / least-privilege guidance.
- Migrating an existing repo onto BC Gov defaults (signed commits, single required check, no force-push).
- Authoring generic GitHub Actions workflows with no BC Gov / security angle — prefer the upstream GitHub Actions docs or a general CI catalogue.
- Authoring reusable / callable workflows that deploy or validate into the BC Gov Azure Landing Zone (e.g., platform-team-owned deploy templates, OpenShift or AKS deploy callers) — defer to the BC Gov
gha-workflowsskill (when installed in your agent environment); this skill covers only the workflow chassis (permissions, pinning, fork-gate, aggregator, ruleset) around such callers, not their deploy logic. - Designing the workflow's domain logic (Bicep what-if, Terraform plan, npm publish mechanics, Pages build) — those belong to the domain skill: sibling
azure-networkingin this catalogue, or upstream Microsoftazure-prepare/azure-deployskills (if installed). This skill covers only the workflow chassis around them. - Provisioning the Azure infrastructure the workflow deploys to — use the matching domain skill (upstream Microsoft
azure-*skills, if installed). - Defining branch-protection rules in code via a Terraform provider — out of scope; this skill describes the settings a ruleset should carry, not the IaC to apply them.
- Debugging self-hosted runner setup, ARC (Actions Runner Controller), or runner-image customization — out of scope.
- Set repository defaults before writing any workflow. In the repo's Settings → Actions → General: set Workflow permissions to Read repository contents and packages permissions (default-deny write); leave Allow GitHub Actions to create and approve pull requests off unless a workflow truly needs it; under Fork pull request workflows from outside collaborators pick Require approval for first-time contributors who are new to GitHub at minimum. These defaults make every later
permissions:block additive instead of subtractive. - Pin the runner OS to a specific version label, never the
*-latestaliases. Useruns-on: ubuntu-24.04for Linux jobs,runs-on: windows-2025(orwindows-2022) for Windows jobs, andruns-on: macos-26(ormacos-15) for macOS jobs — or whichever specific label the repo standardises on. A pinned runner makes the OS upgrade a deliberate PR you review, not a silent rollover during an incident;ubuntu-latest,windows-latest, andmacos-latesteach roll forward on GitHub's own cadence and the breaking-change blast radius differs by platform (Xcode majors and SDK churn on macOS, Visual Studio component updates on Windows, glibc / Python / Node default bumps on Ubuntu). - Pin actions by class: first-party actions owned by GitHub (
actions/*) pin to a major tag (actions/checkout@v6) — GitHub guarantees those tags are forward-only and Dependabot still bumps majors. Third-party actions pin to an immutable commit SHA with the human-readable version in a trailing comment (uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0) so a tag hijack cannot swap the action's code under you. - Declare top-level least-privilege
permissions:— prefer the deny-allpermissions: {}baseline. Start every workflow withpermissions: {}(empty mapping = deny everything) and add only the specific scopes each job needs (e.g.contents: readon the checkout job,packages: writeon the publish job,pages: write+id-token: writeon the deploy job,pull-requests: writeon the bot-comment job). Anything that doesn't appear in the block is implicitly denied. If a workflow legitimately needscontents: readeverywhere (e.g. all jobs check out the repo), set that at workflow scope and keep every elevation per-job. - Harden every non-trivial
run:block withset -euo pipefailas the first line (Bash is the default shell onubuntu-*runners). Chain related commands with&&rather than newlines when you want them to be one logical step that stops on the first failure. Withoutpipefail, a failure on the left side of a|is silently dropped; without-u, an unset variable becomes an empty string that quietly corrupts commands. - Add a fork-gate as the first job on any
pull_requestworkflow in a public repo. The job comparesgithub.event.pull_request.head.repo.full_nametogithub.repositoryandexit 1s when they differ, so maintainers re-land fork PRs from an in-repo branch after review. This keeps CI minutes (and any future token) off untrusted code. - Wire a
resultsaggregator job withif: always(),needs: [every-other-job], and a step that fails when anyneeds.*.resultisfailureorcancelled. Make this single job the only required status check in branch protection — then renaming or splitting upstream jobs never requires touching the ruleset. Treatskippedas a pass so conditional jobs don't false-fail the gate. - Constrain the trigger surface. On
push/pull_request, addpaths:filters so the workflow only runs when files it cares about change (skills/**for the publish workflow,docs/**for Pages, etc.). On scheduled or long-running workflows addconcurrency: { group: "<stable-key>", cancel-in-progress: <true|false> }—truefor build/test,falsefor deploy / publish to avoid mid-flight cancellation. - Pass untrusted input only through
env:, never via${{ … }}interpolation in arun:block. PR titles, branch names, issue bodies, and PR head SHAs are attacker-controlled. Bind them to environment variables in anenv:map and reference them with shell quoting ("$PR_TITLE") so a; rm -rf /payload becomes a literal string, not a command. - Use
pull_request_targetonly when you must mutate the PR (approve, label, auto-merge) and never combine it withactions/checkoutof the PR head without an explicit reviewer gate —pull_request_targetruns with the base repo's tokens. Limit those workflows toif: github.event.pull_request.user.login == 'dependabot[bot]'(or an equivalent allow-list) and a tightconcurrencygroup to avoid race conditions onsynchronize. - Configure Dependabot in two files.
.github/dependabot.ymlenables thegithub-actionsecosystem (and any others —npm,pip,uv,docker) so every pinned action and runtime gets PR-bumped, and uses a Conventional-Commitcommit-message.prefixkeyed to the ecosystem (deps(actions),deps(npm),deps(pip),deps(uv)). Pair it with a smalldependabot-auto-merge.ymltriggered onpull_request_targetthat approves and enables auto-merge only fordependabot[bot]PRs, with a deny-allpermissions: {}at the workflow scope andpermissions: { contents: write, pull-requests: write }scoped to the single auto-merge job. - Require signed commits on
main. Either via the GitHub branch ruleset (Settings → Rules → Rulesets) or the legacy branch protection page: enable Require signed commits, Require a pull request before merging, point Require status checks at the singleresultsjob, set Require strict status checks, and disable Allow force pushes and Allow deletions. Signed commits + a single required check is the BC Gov default — keep workflow PRs themselves signed so they don't lock you out. - Use Conventional Commits for workflow / action PRs, with the scope derived from the primary directory touched:
ci(workflows): ...for.github/workflows/**edits,ci(scripts): ...for.github/scripts/**,deps(actions): ...for pinned-action bumps. This is what the BC Gov coding standard expects and what Dependabot is already configured to emit — keep human PRs consistent so changelogs and release-notes generation stay clean. - Validate locally before opening a PR. Lint YAML (
yamllint .github/workflows/), shellcheck any non-trivialrun:blocks (or useactionlintwhich extracts and shellchecks them for you), and dry-run withactfor fast iteration on the matrix surface. For this repo specifically, also runmake lint test validateso theresultscheck passes on the first push.
- Always pin first-party
actions/*to a major tag, third-party actions to an immutable commit SHA + version comment, and runner tools/binaries to a specific release version (never pipe unpinned installer scripts from raw CDN URLs). (Why: GitHub forward-guaranteesactions/*major tags; third-party tags are mutable and a hijacked release can ship an exfil payload to every workflow that uses it; unpinned CDN scripts are a security and rate-limiting risk. Dependabot bumps both actions styles, so you lose nothing.) - Always declare a top-level
permissions:block —permissions: {}(deny-all) as the strongest default, orpermissions: { contents: read }when every job legitimately needs to read the repo — and step up to anywritescope only on the single job that needs it. (Why: workflows inherit the repo's Workflow permissions default; setting it at workflow scope makes least-privilege a code-review artefact instead of a UI checkbox that can drift.permissions: {}is the BC Gov standard baseline.) - Always start non-trivial
run:blocks withset -euo pipefailand chain commands you want short-circuited with&&. (Why: Bash withoutpipefailsilently swallows failures upstream of a|; without-uan unset variable becomes""and a missing secret turns into a quietly successful no-op instead of a loud failure.&&makes the dependency between commands part of the shell script, not assumed.) - Always pin the runner to a specific OS version label —
ubuntu-24.04for Linux,windows-2025(orwindows-2022) for Windows,macos-26(ormacos-15) for macOS — never the*-latestaliases (ubuntu-latest,windows-latest,macos-latest). (Why: every*-latestalias silently rolls to a new major version on GitHub's own cadence — Ubuntu LTS roughly yearly, Windows Server every few years, macOS once a year — and the breaking-change blast radius is real on every platform: Xcode majors and SDK churn on macOS, Visual Studio component updates on Windows, glibc / Python / Node default bumps on Ubuntu. A pinned label makes the upgrade a deliberate, reviewable PR instead of an incident-time surprise. GitHub only supports the latest two GA images per OS, so the pinned label also tells you when the OS itself is approaching deprecation.) - Always make a single
resultsaggregator job the only required status check in branch protection. (Why: required-check names are duplicated into the ruleset by string; renaming or splitting any underlying job is otherwise a coordinated change across two systems. The aggregator decouples them andif: always()lets it run even when an upstream job fails.) - Always add a
fork-gatejob that fails fast on PRs opened from a fork in any public-repopull_requestworkflow. (Why: even withpermissions: read, fork PRs spend CI minutes and any future expansion of the workflow's privileges immediately becomes a token-exfil hole. Maintainers re-land fork work from an in-repo branch.) - Always pass attacker-controlled values (
github.event.pull_request.title,head_ref,issue.body,comment.body) throughenv:and quote them in shell. (Why:${{ … }}is interpolated by the runner before the shell parses, so a payload like"; curl …"becomes a command, not a string.env:+"$VAR"is the only safe pattern.) - Always check the actor on a
pull_request_targetworkflow before doing anything that uses the elevated token. (Why:pull_request_targetruns in the base repo's context with its secrets; without a strict actor gate, a fork PR can trigger a privileged path.) - Never check out the PR's head SHA on a
pull_request_targetworkflow and then run code from it (build/test/install hooks). (Why: that re-introduces the untrusted code into a privileged context — the exact attackpull_request_targetwas designed around.) - Never use a long-lived Personal Access Token where the built-in
GITHUB_TOKENor OIDC will work. (Why: PATs grant their owner's full permissions, don't expire on PR close, and bypass the per-workflowpermissions:ceiling; they also block on the owner leaving the org.) - Always log into a public cloud (Azure or AWS) via OIDC federation, not a stored secret credential. Grant
id-token: writeon the deploy job only, use the official provider action SHA-pinned per Rule #1 (azure/loginoraws-actions/configure-aws-credentials), and pin the cloud-side trust by a subject claim scoped to repo + branch / environment (repo:OWNER/REPO:environment:prodis the strongest shape because it composes with GitHub Environments' required-reviewers gate). (Why: a long-livedAZURE_CLIENT_SECRETor AWS access key insecrets.*is a stealable, non-expiring, environment-blind credential — exactly the blast-radius profile that an action-tag hijack, apull_request_targetslip, or an insider is designed to harvest. OIDC tokens expire in ~5 minutes, are minted only whenid-token: writeis granted on a specific job, and the cloud-side subject pins the GitHub repo + ref so a fork or a non-mainbranch cannot exchange them.) - Never disable signed-commit enforcement on
main, even temporarily. (Why: the BC Gov ruleset treats it as a hard floor; disabling it for one merge requires re-enabling and means any unsigned commit that landed during the gap is permanent.)
- "Add CI to a new repo so PRs run lint and tests" → workflow with
on: pull_request,permissions: { contents: read }, jobsfork-gate → lint-tests → results,actions/checkout@v6+ a pinned setup action,runs-on: ubuntu-24.04, single required check =results. - "Publish an npm package from
mainon a path change" → workflow withon: push: { branches: [main], paths: ["packages/**"] },permissions: { contents: read, packages: write }on the publish job only,actions/setup-node@v6withregistry-url: https://npm.pkg.github.com,NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, and a version-skip step that no-ops when the version is already on the registry. - "Deploy GitHub Pages from
docs/" → workflow withpermissions: { contents: read, pages: write, id-token: write }at workflow scope,concurrency: { group: pages, cancel-in-progress: false }, build job → deploy job,actions/configure-pages@v6,actions/upload-pages-artifact@v5,actions/deploy-pages@v5. - "Deploy to Azure / AWS from a workflow without storing cloud secrets" → OIDC federation on the deploy job:
permissions: { contents: read, id-token: write },environment: prod, thenuses: azure/login@<sha> # v3.0.0with onlyclient-id/tenant-id/subscription-id(noclient-secret), oruses: aws-actions/configure-aws-credentials@<sha> # v6withrole-to-assume+aws-region(noaws-access-key-id/aws-secret-access-key). Cloud-side: create a Federated Identity Credential (Entra) or IAM OIDC trust (AWS) whose subject pins the workflow — preferrepo:OWNER/REPO:environment:prodso the OIDC token is only mintable after the Environment's required-reviewers gate runs. - "Branch protection started failing after I renamed a job" → required check name no longer matches; collapse all jobs behind a single
resultsaggregator and require only that one. Future renames stop touching the ruleset. - "Dependabot auto-merge isn't merging the PR" → check the trigger is
pull_request_target(notpull_request), the actor gate isdependabot[bot]literally, the workflow haspermissions: { contents: write, pull-requests: write }, and the repo allows auto-merge in Settings → General → Pull Requests. - "
Resource not accessible by integrationon agh pr reviewstep" → the job is missingpermissions: { pull-requests: write }; add it at job scope, not workflow scope, so the rest of the workflow stays read-only.
- The repo predates the Workflow permissions: read default → existing workflows may inherit
write-alleven with nopermissions:block. Add an explicitpermissions: { contents: read }to every workflow before flipping the repo default, otherwise jobs that quietly relied on the implicit write will start failing on push. - A required action only ships a tag, never a release SHA you can trust → fork the action into a BC Gov-owned repo, pin the fork by SHA, and let Dependabot track upstream for review. Pinning a mutable tag on a third-party action is the explicit threat the SHA pin defends against.
- A workflow needs to write to the repo and read a secret only on
main→ split into two jobs. Keeppermissions: { contents: read }at workflow scope; on the privileged job add the narrowerpermissions:block and gate it withif: github.ref == 'refs/heads/main' && github.event_name == 'push'so PR runs never see the elevated scope. - Dependabot PRs can't see secrets → that is by design. If a Dependabot PR's CI needs a secret to pass, route it through a separate scheduled / manual workflow that runs against
mainafter merge, or grant the secret to Dependabot specifically via Settings → Secrets and variables → Dependabot. - A required
resultscheck is reportingskippedafter a refactor → branch protection treatsskippedas success by design (so conditional jobs don't false-fail the gate). If you genuinely wantskippedto block, make the aggregator's failure step alsoexit 1oncontains(needs.*.result, 'skipped'). - Signed-commit policy blocks a Dependabot or auto-merge PR → either enable Dependabot's signed-commit support (it signs as
web-flowvia the GitHub API) or have the auto-merge workflow create a signed merge commit throughgh pr merge --auto --squashso the merge itself is signed by GitHub even when the PR commits weren't. pull_request_targetworkflow needs the PR's code (e.g. to read a config file the PR added) → check out the base ref, then read only the specific files you trust from the PR usinggh apiorgit show <pr-sha>:<path>into a variable; neveractions/checkoutthe head SHA with the default token still in scope.- A third-party action requires a
GITHUB_TOKENwithwriteyou don't want to grant → most of the time you don't actually need it; passwith: token: ${{ github.token }}only on the step that needs it, and prefer a minimalpermissions:block on that job over expanding workflow-scope perms. - OIDC cloud login fails with
AADSTS70021: No matching federated identity record found(Azure) orNot authorized to perform sts:AssumeRoleWithWebIdentity(AWS) — the cloud-side federated-credential subject claim doesn't match the workflow's actualsub. Fetch the OIDC token from inside the deploy job and decode its payload to see the livesub(the runner exposes the JWT-issuing endpoint via$ACTIONS_ID_TOKEN_REQUEST_URLand an opaque bearer in$ACTIONS_ID_TOKEN_REQUEST_TOKEN):Or enablecurl -sSL -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \ | jq -r .value \ | cut -d. -f2 \ | base64 -d 2>/dev/null \ | jq .sub
ACTIONS_STEP_DEBUG: trueand re-read the provider action's (azure/login,aws-actions/configure-aws-credentials) own debug output, which prints the subject it sent. Update the Federated Identity Credential / IAM trust to match — typicallyrepo:OWNER/REPO:environment:NAMEfor environment-gated deploys,repo:OWNER/REPO:ref:refs/heads/mainfor branch-gated, orrepo:OWNER/REPO:pull_requestfor PR-time runs (rarely granted). Never unblock by adding aclient-secret/aws-access-key-id— that silently regresses the deploy to a stored credential.
This repo is the canonical worked example. Read .github/workflows/pr.yml, .github/workflows/pages.yml, and .github/workflows/dependabot-auto-merge.yml first — they implement every pattern above, fully commented.
See references/REFERENCE.md for an annotated end-to-end PR workflow with fork-gate + aggregator, a publish workflow with version-skip and least-privilege scoping, the Dependabot config + auto-merge pair, an OIDC cloud-login worked example (Azure / AWS) with a federated-credential subject-claim cookbook, a permissions: scope lookup table, a pinning-style decision matrix, the branch-protection / ruleset settings checklist, and a threat-model section mapping each rule to the attack it defends against (tag hijack, fork-PR token exfil, script injection, pull_request_target abuse, PAT sprawl, long-lived cloud-secret exfil).
For broader topics that live elsewhere, prefer these upstream skills / docs instead of duplicating their guidance here. Precedence: when generic upstream guidance below conflicts with this skill, the BC Gov rule wins (e.g. the deny-all permissions: {} baseline, third-party-action SHA pinning, the single results required check, signed-commit enforcement on main).
- Generic GitHub Actions CI/CD chassis (workflow structure, jobs, matrix, caching, deployment strategies) →
github-actions-ci-cd-best-practicesfromgithub/awesome-copilot - Generic GitHub Actions syntax, triggers, expressions → GitHub Actions docs
- Hardening guidance from GitHub itself → Security hardening for GitHub Actions
- OIDC-to-Azure federation for keyless deploys → upstream Microsoft
azure-prepare/azure-deployskills (if installed) - Authoring the skill profile this workflow ships →
.github/skills/skill-author