Harden GitHub across many repositories: rulesets, CODEOWNERS, merge queue, reusable workflows, least-privilege tokens, SHA pinning, OIDC to AWS, environment approvals, ephemeral runners, and pull_request_target pitfalls.
A handful of repositories can get by on team habits. Hundreds cannot. At that scale the default branch, the CI system, and the path to production all need rules that are enforced centrally, are hard to bypass quietly, and do not depend on every team getting YAML right.
Classic branch protection attaches a rule to one branch name pattern in one repository. Only one rule applies to a given branch, and it cannot be managed across an organization as a single object. Repository rulesets replace this with named policies that have a target, an enforcement status, and a bypass list. Several rulesets can apply to the same ref, and they stack, with the most restrictive result winning.
| Capability | Classic branch protection | Rulesets |
|---|---|---|
| Rules per branch | One matching rule applies | Multiple rulesets layer together |
| Scope | Single repository | Repository or organization, depending on plan |
| Targets | Branches | Branches, tags, and push rules |
| Trial mode | None | Enforcement can be disabled or, on supported plans, set to evaluate |
| Bypass | Admin toggle and limited allow lists | Explicit list of roles, teams, and apps, including PR-only bypass |
| Repository admin override | Admin can edit the rule | Organization rulesets cannot be edited by repository admins |
Both systems can coexist, and their rules combine, so you can migrate one repository at a time. Rulesets are worth keeping in version control. This definition protects the default branch:
{
"name": "protect-default-branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" }
],
"conditions": {
"ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [{ "context": "ci / test" }]
}
}
]
}
gh api -X POST repos/example-org/example-repo/rulesets --input ruleset.json
gh ruleset list --repo example-org/example-repo
Actor ID 5 is the built-in repository admin role, and bypass_mode: pull_request means an admin may merge past the rule in a pull request, where the bypass is recorded, but may not push directly. require_last_push_approval closes a real gap: without it, an author can get approval, push another commit, and merge their own unreviewed change.
A required status check matches on the check name, and that name has sharp edges:
caller-job / called-job. Renaming either one changes the check name, and the rule then waits forever for a check that will never arrive.test (3.12). Changing the matrix has the same effect. A single final job that needs the whole matrix gives you one stable name to require. Give it if: always() and fail it when a dependency failed, or a failed matrix skips it, and a skipped job counts as passing.paths or branches filters never reports, so the check stays pending and blocks the merge. A job skipped by an if condition reports success. To skip work on docs-only changes, keep the workflow running and put the condition on jobs.A CODEOWNERS file maps paths to reviewers and becomes a gate when the ruleset sets require_code_owner_review. GitHub looks in .github/, the repository root, and docs/, using the first file it finds. It reads the version on the pull request's base branch, so a pull request cannot grant itself new owners.
# Default owner
* @example-org/platform
# Service ownership
/apps/billing/ @example-org/billing
/apps/checkout/ @example-org/payments
# Delivery paths need platform review regardless of service
/.github/ @example-org/platform
/deploy/ @example-org/platform @example-org/sre
# Ownership rules protect themselves
/.github/CODEOWNERS @example-org/platform
The rule that causes most mistakes: the last matching pattern wins, entirely. Patterns do not add up. If you append *.md @example-org/docs at the end of this file, then apps/billing/README.md belongs only to the docs team. Order lines from general to specific, and treat broad globs near the bottom as suspicious.
Other behaviours worth knowing:
! and character ranges are not supported.Because invalid lines fail silently, check for errors in CI:
gh api repos/example-org/example-repo/codeowners/errors --jq '.errors'
Without a matching ruleset, CODEOWNERS only requests reviews. It does not block anything.
Two pull requests can each pass CI against main and still break it once both land. Requiring branches to be up to date fixes this but forces serial rebases and reruns. A merge queue instead builds a temporary branch containing the target plus everything ahead in the queue, runs required checks there, and merges only if they pass. A failing entry is removed and the rest rebuild.
The most common setup failure: workflows must listen for the merge_group event. Without this, required checks never run on the temporary branch and queue entries time out.
name: ci
on:
pull_request:
merge_group:
types: [checks_requested]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make test
integration:
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make integration-test
The integration job shows a useful pattern. On a pull request it is skipped and reports success, so it can be a required check without slowing every push. In the queue it runs for real, so expensive suites execute only for code about to merge.
The trade-offs: parallel builds multiply CI spend, and a flaky test now ejects innocent pull requests and resets work behind them, so fix flakiness first. Availability depends on repository type and plan. In a ruleset, the merge_queue rule sets merge method, grouping, entry limits, and check timeout. Keep the timeout above your slowest realistic CI run.
Both remove duplication, but at different levels. A composite action packages steps that run inside the caller's job. A reusable workflow packages whole jobs, including their runner choice, permissions, and environment.
| Property | Composite action | Reusable workflow |
|---|---|---|
| Granularity | Steps in a job | One or more jobs |
| Called from | steps[*].uses | jobs.<id>.uses |
| Choose runner | No, inherits the job | Yes |
| Set environment | No | Yes, per job |
| Secrets | Passed as inputs, no secrets context | Named secrets or inherit |
| Identity | Same as caller | Named in OIDC claim job_workflow_ref |
Use composite actions for setup and small shared procedures. Use reusable workflows when the point is to enforce a process, because a platform team can own the whole deployment job and application teams cannot edit it from their repository. That last row of the table is what makes this enforceable, as the OIDC section shows.
A composite action must set shell on each run step.
A reusable deployment workflow in a central repository:
# example-org/platform-workflows/.github/workflows/deploy.yml
name: deploy
on:
workflow_call:
inputs:
environment:
type: string
required: true
image-digest:
type: string
required: true
role-arn:
type: string
required: true
permissions: {}
jobs:
deploy:
runs-on: ubuntu-24.04
environment: ${{ inputs.environment }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: ${{ inputs.role-arn }}
role-session-name: gha-${{ github.run_id }}
aws-region: eu-west-1
- name: Deploy
env:
IMAGE_DIGEST: ${{ inputs.image-digest }}
TARGET_ENV: ${{ inputs.environment }}
run: ./deploy/release.sh "$TARGET_ENV" "$IMAGE_DIGEST"
And its caller:
name: release
on:
push:
branches: [main]
permissions: {}
jobs:
production:
# Replace with the full commit SHA of the tagged release you reviewed
uses: example-org/platform-workflows/.github/workflows/deploy.yml@0123456789abcdef0123456789abcdef01234567
permissions:
contents: read
id-token: write
with:
environment: production
image-digest: ${{ vars.CHECKOUT_DIGEST }}
role-arn: arn:aws:iam::123456789012:role/example-repo-deploy
Points to watch:
id-token: write.secrets: inherit across trust boundaries. It hands every accessible secret to the called workflow, even ones it never uses. Name them instead.environment on the job in the called workflow, as above, and it reads them directly.Each job receives a short-lived GITHUB_TOKEN. Its power comes from the repository default and the permissions key. Set the default to read-only at the organization level, then grant per job.
gh api -X PUT repos/example-org/example-repo/actions/permissions/workflow \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=false
Use permissions: {} at the top of each workflow, so a new job starts with nothing, and declare what each job needs. Setting any scope sets all unlisted scopes to none.
| Task | Permission |
|---|---|
| Clone the repository | contents: read |
| Create a tag or release | contents: write |
| Comment on a pull request | pull-requests: write |
| Push to the package registry | packages: write |
| Request an OIDC token | id-token: write |
Scope write permissions to the single job that needs them. A build job running third-party dependencies should not share a job with a token that can push. Also set persist-credentials: false on checkout, because by default the token is stored in the local Git config, where any later step in the job, including a malicious post-install script, can read it. Events created by GITHUB_TOKEN do not start new workflow runs, apart from workflow_dispatch and repository_dispatch. When you need that, use a short-lived GitHub App installation token, not a personal token.
A reference like @v4 is a mutable Git tag. Whoever controls the action's repository can move it, and in March 2025 attackers did exactly that with a popular action: they repointed its tags at a commit that dumped runner secrets into build logs, and workflows pinned to a tag picked it up on their next run. A full commit SHA is the only immutable reference.
# Weak: tag can be moved
- uses: actions/checkout@v7
# Strong: exact commit, with a comment for humans and tooling
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Dependabot keeps pinned SHAs current and updates the version comment:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
Limits of pinning, which are easy to overlook:
action.yml pulls image:latest is still mutable at run time. Prefer actions that pin images by digest.object.type is tag, dereference it to reach the commit.gh api repos/actions/checkout/git/ref/tags/v7.0.1 \
--jq '.object.type + " " + .object.sha'
On top of pinning, use the organization policy that restricts which actions may run, and the policy that requires full-length SHA references, so that unpinned usage fails instead of relying on review. An unmaintained pin misses security fixes, so treat the Dependabot queue as part of the control.
A stored cloud access key is a credential that does not expire, cannot be limited to one branch, and can be exfiltrated by any step that reads it. With OIDC, a job requests a signed token from GitHub describing itself, and the cloud provider exchanges that token for short-lived credentials if its trust policy matches. No cloud credential is stored in GitHub.
The issuer is GitHub's token service, https://token.actions.githubusercontent.com. On AWS, create the provider once per account:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
All security lives in the trust policy. This one allows a single repository, only in the production environment:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:environment:production"
}
}
}
]
}
The sub claim's format depends on how the job runs, which is the top cause of "not authorized to perform sts:AssumeRoleWithWebIdentity":
| Job context | Default sub |
|---|---|
| Environment set | repo:example-org/example-repo:environment:production |
| Branch push | repo:example-org/example-repo:ref:refs/heads/main |
| Tag push | repo:example-org/example-repo:ref:refs/tags/v1.2.0 |
| Pull request | repo:example-org/example-repo:pull_request |
Once a job sets an environment, the ref form no longer appears, so a policy matching on the branch stops working. Never use a wildcard such as repo:example-org/* or a missing sub condition. Either lets any repository in the organization, or in the worst case any GitHub repository, assume the role.
The default sub says which repository is deploying, not how. To require your central deployment workflow, include job_workflow_ref in the subject:
cat > oidc-sub.json <<'EOF'
{ "use_default": false, "include_claim_keys": ["repo", "context", "job_workflow_ref"] }
EOF
gh api -X PUT repos/example-org/example-repo/actions/oidc/customization/sub \
--input oidc-sub.json
The subject then reads as follows, and the trust policy can match it exactly:
repo:example-org/example-repo:environment:production:job_workflow_ref:example-org/platform-workflows/.github/workflows/deploy.yml@refs/heads/main
This pins the workflow reference too. The suffix is whatever ref the caller used, so a caller pinned to a SHA produces that SHA here instead of refs/heads/main. Decide on one reference convention, such as a protected release tag, and use StringLike only where you have thought through what the wildcard admits. Changing subject format breaks existing trust policies, so update both together.
The same model applies elsewhere. Google Cloud Workload Identity Federation restricts claims with an attribute condition, such as assertion.repository == 'example-org/example-repo'. An Azure federated credential matches the exact subject string. Test a negative case on every provider.
An environment is a named deployment target with protection rules. A job that references it waits until the rules pass, and only then does it receive environment secrets. Gates include required reviewers, self-review prevention, a wait timer, branch and tag restrictions, and custom protection rules backed by a GitHub App. Availability on private repositories varies by plan. All of these can be set in the UI or with the REST endpoint PUT /repos/{owner}/{repo}/environments/{name}.
An environment gate on its own is advisory if the cloud role does not depend on it. Anyone able to push a workflow could write a job that skips the environment line and assumes the role directly, unless the trust policy requires the environment:production subject. Combining the two is what makes the approval binding: no approval, no matching sub, no credentials.
Also turn off the setting that lets administrators bypass protection rules, and keep branch restrictions on so a feature branch cannot target production.
Self-hosted runners give you private network access, specific hardware, and custom images. They also run arbitrary code from workflows on your infrastructure, without the throwaway VM that GitHub-hosted runners provide.
| Risk | How it happens | Mitigation |
|---|---|---|
| Persistence | A job leaves a modified tool, cron entry, or background process that affects later jobs | Ephemeral runners, rebuilt for each job |
| Secret theft across jobs | Leftover files, caches, or memory on a shared host | One job per machine, no shared disks |
| Fork pull request code | Public repository sends untrusted code to your runner | Do not use self-hosted runners for public repositories |
| Internal network reach | Job reaches databases, cloud metadata service, internal APIs | Isolated subnet, default-deny egress, metadata access blocked |
| Host escape | Mounted Docker socket or privileged containers | Rootless builds or VM isolation; avoid mounting the socket |
Non-ephemeral runners are the main hazard. --ephemeral makes the runner take one job and deregister, but it does not clean the machine. Pair it with a fresh VM or container per job. Just-in-time registration is a better fit than long-lived registration tokens, because the configuration is single-use and bound to one runner:
#!/usr/bin/env bash
# Runs on a fresh VM. The controller creating the VM holds the API credential.
set -euo pipefail
JIT_CONFIG=$(gh api -X POST orgs/example-org/actions/runners/generate-jitconfig \
-f name="build-$(date +%s)-$RANDOM" \
-F runner_group_id=7 \
-f 'labels[]=linux' -f 'labels[]=x64' -f 'labels[]=build' \
-f work_folder=_work \
--jq .encoded_jit_config)
cd /srv/app/actions-runner
./run.sh --jitconfig "$JIT_CONFIG"
# The runner exits after one job. Destroy the VM here, do not reuse it.
On Kubernetes, Actions Runner Controller with runner scale sets gives the same one-job-per-pod model with autoscaling, through the gha-runner-scale-set Helm chart, and the release name becomes the runs-on value. Be careful with containerMode.type: dind, because it runs a privileged sidecar. Use a dedicated node pool and strict network policies.
Target runners by group rather than the bare self-hosted label:
runs-on:
group: build-runners
labels: [linux, x64]
Separate build runners, which execute untrusted dependency code, from deploy runners, which have production network reach, and limit each group to selected repositories. Ephemeral designs lose warm caches, and a shared build cache is itself a path for poisoning between jobs.
The pull_request event from a fork runs with a read-only token and no secrets. pull_request_target was added for tasks like labelling, and it runs in the context of the base repository: with secrets and a write-capable token. The workflow file comes from the default branch, so the attacker cannot edit the workflow. The danger starts when the workflow touches attacker-controlled content.
The classic vulnerable pattern checks out the pull request and executes it:
# DANGEROUS: do not use
on: pull_request_target
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Build tooling, lifecycle scripts, Makefiles, local actions, and linter configs can all run code, not just test scripts. The same risk applies to workflow_run when it acts on what an untrusted run produced.
The second trap is expression injection. ${{ }} is expanded into the script before the shell runs, so a pull request titled with shell metacharacters becomes code:
# Vulnerable
- run: echo "Title: ${{ github.event.pull_request.title }}"
# Safe: the shell sees a variable, not spliced text
- run: echo "Title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
The rule also covers branch names, issue bodies, and commit messages, in every event type, not just this one.
The safe pattern separates untrusted work from privileged work:
# .github/workflows/pr-build.yml: untrusted, no secrets
name: pr-build
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: make coverage-report
- name: Record the pull request number
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: jq --argjson pr "$PR_NUMBER" '. + {pr: $pr}' coverage.json > report.json
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage
path: report.json
# .github/workflows/pr-comment.yml: privileged, runs no PR code
name: pr-comment
on:
workflow_run:
workflows: [pr-build]
types: [completed]
permissions: {}
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
permissions:
actions: read
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
REPO: ${{ github.repository }}
run: |
gh run download "$RUN_ID" -n coverage -D "$RUNNER_TEMP/artifact" -R "$REPO"
# Treat the file as hostile data: validate types, never execute it
PR=$(jq -e '.pr | numbers' "$RUNNER_TEMP/artifact/report.json")
PERCENT=$(jq -e '.percent | numbers' "$RUNNER_TEMP/artifact/report.json")
gh pr comment "$PR" -R "$REPO" --body "Coverage: ${PERCENT}%"
The artifact crosses a trust boundary, so parse it as data with a strict schema. Do not extract it into the workspace, source it, write its content to $GITHUB_ENV, or use it in an expression. The PR number comes from that same untrusted file, so the worst a forged value can do here is put a numeric comment on the wrong pull request. Keep the privileged job that narrow. Label-gated workflows are weaker: attacker code still runs with secrets, even when pinned to the reviewed head SHA.
Since a platform change in late 2025, pull_request_target always takes its workflow file from the default branch, and environment branch rules evaluate against that ref rather than the pull request head. Environment branch filters will not stop these runs if the default branch is allowed.
Controls that were never tested tend not to exist. Run these against a test repository, then sample production repositories on a schedule.
# Which rules apply to the default branch?
gh ruleset check main --repo example-org/example-repo
# Expect rejection: direct push to a protected branch
git commit --allow-empty -m "ruleset test" && git push origin main
# CODEOWNERS parses cleanly: expect an empty list
gh api repos/example-org/example-repo/codeowners/errors --jq '.errors'
# Default token is read-only: expect "read"
gh api repos/example-org/example-repo/actions/permissions/workflow \
--jq '.default_workflow_permissions'
# Any action not pinned to a 40-character SHA? Expect no output
grep -rnE '^\s*-?\s*uses:\s+[^./]' .github/ | grep -vE '@[0-9a-f]{40}'
# Risky triggers to review by hand
grep -rnE 'pull_request_target|workflow_run' .github/workflows/
# Static analysis of workflows
actionlint
zizmor .github/workflows/
Then run behavioural checks, including negative ones:
AssumeRoleWithWebIdentity. A pass here means the trust policy is too broad.$(id) and check that the logs show it as literal text.gh secret list at repository and organization level. Once OIDC is in use there should be no long-lived cloud keys left, and any that were replaced should be revoked at the provider, not just deleted from GitHub.Found a mistake in this tutorial? Let us know and we will fix it.