| 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 | ["github-actions","ci","cd","security","bcgov","devops"] |
GitHub Actions — BC Gov Hardening Patterns
Use When
- 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 other GITHUB_TOKEN permission errors.
- Adding or auditing Dependabot — both
.github/dependabot.yml and 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).
Don't Use When
- 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-workflows skill (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-networking in this catalogue, or upstream Microsoft azure-prepare / azure-deploy skills (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.
Workflow
- 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
*-latest aliases. Use runs-on: ubuntu-24.04 for Linux jobs, runs-on: windows-2025 (or windows-2022) for Windows jobs, and runs-on: macos-26 (or macos-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, and macos-latest each 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-all permissions: {} baseline. Start every workflow with permissions: {} (empty mapping = deny everything) and add only the specific scopes each job needs (e.g. contents: read on the checkout job, packages: write on the publish job, pages: write + id-token: write on the deploy job, pull-requests: write on the bot-comment job). Anything that doesn't appear in the block is implicitly denied. If a workflow legitimately needs contents: read everywhere (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 with set -euo pipefail as the first line (Bash is the default shell on ubuntu-* runners). Chain related commands with && rather than newlines when you want them to be one logical step that stops on the first failure. Without pipefail, 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_request workflow in a public repo. The job compares github.event.pull_request.head.repo.full_name to github.repository and exit 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
results aggregator job with if: always(), needs: [every-other-job], and a step that fails when any needs.*.result is failure or cancelled. Make this single job the only required status check in branch protection — then renaming or splitting upstream jobs never requires touching the ruleset. Treat skipped as a pass so conditional jobs don't false-fail the gate.
- Constrain the trigger surface. On
push/pull_request, add paths: 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 add concurrency: { group: "<stable-key>", cancel-in-progress: <true|false> } — true for build/test, false for deploy / publish to avoid mid-flight cancellation.
- Pass untrusted input only through
env:, never via ${{ … }} interpolation in a run: block. PR titles, branch names, issue bodies, and PR head SHAs are attacker-controlled. Bind them to environment variables in an env: map and reference them with shell quoting ("$PR_TITLE") so a ; rm -rf / payload becomes a literal string, not a command.
- Use
pull_request_target only when you must mutate the PR (approve, label, auto-merge) and never combine it with actions/checkout of the PR head without an explicit reviewer gate — pull_request_target runs with the base repo's tokens. Limit those workflows to if: github.event.pull_request.user.login == 'dependabot[bot]' (or an equivalent allow-list) and a tight concurrency group to avoid race conditions on synchronize.
- Configure Dependabot in two files.
.github/dependabot.yml enables the github-actions ecosystem (and any others — npm, pip, uv, docker) so every pinned action and runtime gets PR-bumped, and uses a Conventional-Commit commit-message.prefix keyed to the ecosystem (deps(actions), deps(npm), deps(pip), deps(uv)). Pair it with a small dependabot-auto-merge.yml triggered on pull_request_target that approves and enables auto-merge only for dependabot[bot] PRs, with a deny-all permissions: {} at the workflow scope and permissions: { 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 single results job, 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-trivial run: blocks (or use actionlint which extracts and shellchecks them for you), and dry-run with act for fast iteration on the matrix surface. For this repo specifically, also run make lint test validate so the results check passes on the first push.
Rules
- 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-guarantees actions/* 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, or permissions: { contents: read } when every job legitimately needs to read the repo — and step up to any write scope 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 with set -euo pipefail and chain commands you want short-circuited with &&. (Why: Bash without pipefail silently swallows failures upstream of a |; without -u an 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.04 for Linux, windows-2025 (or windows-2022) for Windows, macos-26 (or macos-15) for macOS — never the *-latest aliases (ubuntu-latest, windows-latest, macos-latest). (Why: every *-latest alias 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
results aggregator 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 and if: always() lets it run even when an upstream job fails.)
- Always add a
fork-gate job that fails fast on PRs opened from a fork in any public-repo pull_request workflow. (Why: even with permissions: 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) through env: 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_target workflow before doing anything that uses the elevated token. (Why: pull_request_target runs 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_target workflow and then run code from it (build/test/install hooks). (Why: that re-introduces the untrusted code into a privileged context — the exact attack pull_request_target was designed around.)
- Never use a long-lived Personal Access Token where the built-in
GITHUB_TOKEN or OIDC will work. (Why: PATs grant their owner's full permissions, don't expire on PR close, and bypass the per-workflow permissions: 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: write on the deploy job only, use the official provider action SHA-pinned per Rule #1 (azure/login or aws-actions/configure-aws-credentials), and pin the cloud-side trust by a subject claim scoped to repo + branch / environment (repo:OWNER/REPO:environment:prod is the strongest shape because it composes with GitHub Environments' required-reviewers gate). (Why: a long-lived AZURE_CLIENT_SECRET or AWS access key in secrets.* is a stealable, non-expiring, environment-blind credential — exactly the blast-radius profile that an action-tag hijack, a pull_request_target slip, or an insider is designed to harvest. OIDC tokens expire in ~5 minutes, are minted only when id-token: write is granted on a specific job, and the cloud-side subject pins the GitHub repo + ref so a fork or a non-main branch 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.)
Examples
- "Add CI to a new repo so PRs run lint and tests" → workflow with
on: pull_request, permissions: { contents: read }, jobs fork-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
main on a path change" → workflow with on: push: { branches: [main], paths: ["packages/**"] }, permissions: { contents: read, packages: write } on the publish job only, actions/setup-node@v6 with registry-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 with permissions: { 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, then uses: azure/login@<sha> # v3.0.0 with only client-id / tenant-id / subscription-id (no client-secret), or uses: aws-actions/configure-aws-credentials@<sha> # v6 with role-to-assume + aws-region (no aws-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 — prefer repo:OWNER/REPO:environment:prod so 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
results aggregator 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 (not pull_request), the actor gate is dependabot[bot] literally, the workflow has permissions: { contents: write, pull-requests: write }, and the repo allows auto-merge in Settings → General → Pull Requests.
- "
Resource not accessible by integration on a gh pr review step" → the job is missing permissions: { pull-requests: write }; add it at job scope, not workflow scope, so the rest of the workflow stays read-only.
Edge Cases
References
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-practices from github/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-deploy skills (if installed)
- Authoring the skill profile this workflow ships →
.github/skills/skill-author