github-actions

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.

bcgov/agent-skills3 installsApache-2.0Synced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
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.
license: Apache-2.0
---

# 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](https://docs.github.com/actions) 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`](../azure-networking/SKILL.md) 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
1. **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.
2. **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).
3. **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.
4. **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.
5. **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.
6. **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 1`s 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.
7. **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.
8. **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.
9. **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.
10. **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`.
11. **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.
12. **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.
13. **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.
14. **Validate locally before opening a PR.** Lint YAML (`yamllint .github/workflows/`), shellcheck any non-trivial `run:` blocks (or use [`actionlint`](https://github.com/rhysd/actionlint) which extracts and shellchecks them for you), and dry-run with [`act`](https://github.com/nektos/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
- **The repo predates the *Workflow permissions: read* default** → existing workflows may inherit `write-all` even with no `permissions:` block. Add an explicit `permissions: { 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. Keep `permissions: { contents: read }` at workflow scope; on the privileged job add the narrower `permissions:` block and gate it with `if: 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 `main` after merge, or grant the secret to Dependabot specifically via *Settings → Secrets and variables → Dependabot*.
- **A required `results` check is reporting `skipped` after a refactor** → branch protection treats `skipped` as success by design (so conditional jobs don't false-fail the gate). If you genuinely want `skipped` to block, make the aggregator's failure step also `exit 1` on `contains(needs.*.result, 'skipped')`.
- **Signed-commit policy blocks a Dependabot or auto-merge PR** → either enable Dependabot's signed-commit support (it signs as `web-flow` via the GitHub API) or have the auto-merge workflow create a signed merge commit through `gh pr merge --auto --squash` so the merge itself is signed by GitHub even when the PR commits weren't.
- **`pull_request_target` workflow 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 using `gh api` or `git show <pr-sha>:<path>` into a variable; never `actions/checkout` the head SHA with the default token still in scope.
- **A third-party action requires a `GITHUB_TOKEN` with `write` you don't want to grant** → most of the time you don't actually need it; pass `with: token: ${{ github.token }}` only on the step that needs it, and prefer a minimal `permissions:` block on that job over expanding workflow-scope perms.
- **OIDC cloud login fails with `AADSTS70021: No matching federated identity record found` (Azure) or `Not authorized to perform sts:AssumeRoleWithWebIdentity` (AWS)** — the cloud-side federated-credential subject claim doesn't match the workflow's actual `sub`. Fetch the OIDC token from inside the deploy job and decode its payload to see the live `sub` (the runner exposes the JWT-issuing endpoint via `$ACTIONS_ID_TOKEN_REQUEST_URL` and an opaque bearer in `$ACTIONS_ID_TOKEN_REQUEST_TOKEN`):
  ```bash
  curl -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
  ```
  Or enable `ACTIONS_STEP_DEBUG: true` and 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 — typically `repo:OWNER/REPO:environment:NAME` for environment-gated deploys, `repo:OWNER/REPO:ref:refs/heads/main` for branch-gated, or `repo:OWNER/REPO:pull_request` for PR-time runs (rarely granted). **Never** unblock by adding a `client-secret` / `aws-access-key-id` — that silently regresses the deploy to a stored credential.

## 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](./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`](https://raw.githubusercontent.com/github/awesome-copilot/main/instructions/github-actions-ci-cd-best-practices.instructions.md) from `github/awesome-copilot`
- Generic GitHub Actions syntax, triggers, expressions → [GitHub Actions docs](https://docs.github.com/actions)
- Hardening guidance from GitHub itself → [Security hardening for GitHub Actions](https://docs.github.com/actions/security-guides/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`

More Debugging skills

← All Debugging skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY