DevOps Advanced

GitHub at Scale: Rulesets, CODEOWNERS, Reusable Workflows, OIDC, and Self-Hosted Runners

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.

KC Ramo DjangoZen Team Nov 05, 2026 20 min read 3 views

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.

Rulesets vs classic branch protection

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.

CapabilityClassic branch protectionRulesets
Rules per branchOne matching rule appliesMultiple rulesets layer together
ScopeSingle repositoryRepository or organization, depending on plan
TargetsBranchesBranches, tags, and push rules
Trial modeNoneEnforcement can be disabled or, on supported plans, set to evaluate
BypassAdmin toggle and limited allow listsExplicit list of roles, teams, and apps, including PR-only bypass
Repository admin overrideAdmin can edit the ruleOrganization 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.

Required check traps

A required status check matches on the check name, and that name has sharp edges:

  • A required job in a reusable workflow reports as caller-job / called-job. Renaming either one changes the check name, and the rule then waits forever for a check that will never arrive.
  • Matrix jobs report one check per combination, such as 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.
  • A workflow skipped by 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.

CODEOWNERS

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:

  • Multiple owners on one line are alternatives. Any one of them satisfies the requirement. Requiring both of two teams on the same path needs a different mechanism.
  • Owners must have write access. A team must be visible and have explicit write access to the repository, or the line does nothing useful. Invalid lines are skipped, not rejected.
  • Paths are case-sensitive. CODEOWNERS uses most of the gitignore syntax but not all: negation with ! 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.

Merge queue

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.

Reusable workflows vs composite actions

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.

PropertyComposite actionReusable workflow
GranularitySteps in a jobOne or more jobs
Called fromsteps[*].usesjobs.<id>.uses
Choose runnerNo, inherits the jobYes
Set environmentNoYes, per job
SecretsPassed as inputs, no secrets contextNamed secrets or inherit
IdentitySame as callerNamed 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:

  • The called workflow can keep or reduce the caller's token permissions, never raise them. That is why the caller must grant id-token: write.
  • Avoid secrets: inherit across trust boundaries. It hands every accessible secret to the called workflow, even ones it never uses. Name them instead.
  • Environment secrets cannot be passed from a caller. Set environment on the job in the called workflow, as above, and it reads them directly.

Least-privilege GITHUB_TOKEN

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.

TaskPermission
Clone the repositorycontents: read
Create a tag or releasecontents: write
Comment on a pull requestpull-requests: write
Push to the package registrypackages: write
Request an OIDC tokenid-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.

Pinning actions by SHA

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:

  • Transitive references. A composite action you pinned can itself call another action by tag. Your pin does not reach inside it.
  • Container actions. An action pinned by SHA whose action.yml pulls image:latest is still mutable at run time. Prefer actions that pin images by digest.
  • Fork commits. Commits in a fork are reachable through the parent repository's URL, so a SHA alone does not prove the code came from upstream. Resolve it from the tag on the real repository. If 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.

OIDC instead of long-lived cloud secrets

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 contextDefault sub
Environment setrepo:example-org/example-repo:environment:production
Branch pushrepo:example-org/example-repo:ref:refs/heads/main
Tag pushrepo:example-org/example-repo:ref:refs/tags/v1.2.0
Pull requestrepo: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.

Locking a role to a reusable workflow

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.

Environments and required reviewers

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

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.

RiskHow it happensMitigation
PersistenceA job leaves a modified tool, cron entry, or background process that affects later jobsEphemeral runners, rebuilt for each job
Secret theft across jobsLeftover files, caches, or memory on a shared hostOne job per machine, no shared disks
Fork pull request codePublic repository sends untrusted code to your runnerDo not use self-hosted runners for public repositories
Internal network reachJob reaches databases, cloud metadata service, internal APIsIsolated subnet, default-deny egress, metadata access blocked
Host escapeMounted Docker socket or privileged containersRootless 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.

pull_request_target pitfalls

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.

Verifying it works

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:

  1. Open a pull request that touches an owned path and confirm that merge is blocked until the owner approves. Push after approval and confirm approval is required again.
  2. Add a job to a feature branch that assumes the production role without the environment. It must fail at AssumeRoleWithWebIdentity. A pass here means the trust policy is too broad.
  3. Trigger a production deploy and confirm that the job waits for a reviewer and that the author cannot approve it.
  4. From a fork, open a pull request with a title containing $(id) and check that the logs show it as literal text.
  5. Run two jobs in sequence on a self-hosted label. The second must land on a different machine and must not see a marker file the first one wrote.
  6. Run 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.