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.
Works with
---
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
diagnosing-bugs
mattpocock/skills
Diagnosis loop for hard bugs and performance regressions. Use when the user says "diagnose"/"debug this", or reports something broken/throwing/failing/slow.
explore-code
lllllllama/rigorpilot-skills
Rigor Improve implementation leaf skill for auditable candidate implementation in deep learning research repositories. Use when the researcher explicitly authorizes exploratory work on an isolated branch or worktree to transplant modules, adapt a backbone, add LoRA or adapter layers, replace a head, or stitch together meaningful low-risk migration ideas with rollback-aware records in `explore_outputs/`. Do not use for end-to-end exploration orchestration on top of `current_research`, trusted baseline reproduction, conservative debugging, environment setup, verified contribution claims, or default repository analysis.
safe-debug
lllllllama/rigorpilot-skills
Rigor Debug / Rigor Audit skill for deep learning research work. Use when the user pastes a traceback, terminal error, CUDA OOM, checkpoint load failure, shape mismatch, NaN loss symptom, or training failure and wants conservative diagnosis before any patching, with debug fixes clearly separated from research contributions. Do not use for broad refactoring, speculative adaptation, automatic exploratory patching, or general repository familiarization.

