GitHub Actions at Scale: OIDC, Reusable Workflows, ARC and Supply-Chain Security
The first hundred repositories on GitHub Actions are easy. Each team copies a starter workflow, pastes a cloud access key into a repository secret, and ships. By repository five hundred, you have forty slightly different build pipelines, long-lived credentials scattered across organizations, third-party actions referenced by mutable tags, and a self-hosted runner fleet nobody can fully describe. One compromised dependency in that estate becomes an incident that touches every environment at once.
This matters in 2026 because CI/CD is now the most privileged non-human actor in most companies, and attackers have noticed. The March 2025 compromise of a widely used action showed how a single mutable tag can turn into mass credential exposure. The fixes are known and mostly free: short-lived federated identity, centrally owned reusable workflows, ephemeral runners, pinned dependencies and signed provenance.
You will leave with a reference architecture, runnable YAML, a trust-policy design for AWS, GCP and Azure, an Actions Runner Controller layout for Kubernetes, and a list of failure modes that bite at scale.
What this covers: the execution model, OIDC federation, reusable workflows versus composite actions, token and trigger hardening, pinning and the tj-actions incident, ARC scale sets, caching and concurrency, cost, attestations, and a prioritized rollout checklist.
Context and Background
GitHub Actions is an event-driven automation system built into GitHub. A workflow is a YAML file under .github/workflows/. An event (push, pull request, schedule, manual dispatch, a webhook from another workflow) triggers it, the workflow defines one or more jobs, each job runs on a runner, and each job is a sequence of steps that run shell commands or invoke reusable units called actions. Jobs run in parallel unless you declare dependencies with needs. The runner is a fresh virtual machine for GitHub-hosted runners, or a machine you operate for self-hosted ones.
That simplicity is why adoption is so wide, and also why governance breaks down. There is no central scheduler you own, no pipeline server to lock down, and no single configuration file. Every repository is its own pipeline definition, and the marketplace of third-party actions means a workflow is effectively a dependency graph of code you did not write, executed with your credentials.
The incumbents your platform team may compare against, such as Jenkins, GitLab CI and Buildkite, each solve the governance problem differently. Jenkins gives you a central controller you must harden and patch. GitLab ships templates and includes inside one product. Buildkite separates the orchestration plane from agents you run. Actions sits in between: orchestration is SaaS, execution can be either hosted or yours, and policy is expressed through organization settings, rulesets and reusable workflows.
Two sibling topics frame everything here. If you want the deep model for provenance and signing, read our SLSA and Sigstore software supply chain security architecture guide. If you want to know what to do with the artifacts a pipeline emits, see SBOM and VEX with CycloneDX vs SPDX. GitHub’s own security hardening reference for Actions is the primary source for most controls discussed below, and I cite it where a recommendation originates there.
A note on method. Everything in this post that names a limit, a claim or a price comes from GitHub documentation or CISA as read in October 2026. Where I could not confirm a detail, I say so. Limits and prices change, so treat numbers as a snapshot and re-check before you commit them to a design doc.
A Reference Architecture for GitHub Actions at Scale
The reference architecture has five layers that you can adopt independently: identity (OIDC federation replacing stored cloud keys), definition (centrally owned reusable workflows called by thin repository workflows), execution (hosted runners by default, ephemeral self-hosted runners on Kubernetes where you need network access or scale economics), supply chain (pinned dependencies, restricted actions, provenance attestations), and governance (organization policy, rulesets and code owners).

Figure 1: Five-layer reference architecture. A thin caller workflow invokes a platform-owned reusable workflow, which runs on an ephemeral runner and exchanges a GitHub-signed OIDC token for short-lived cloud credentials.
Figure 1 reads left to right. A repository event triggers a short caller workflow that contains almost no logic. The caller references a reusable workflow in a central platform repository, pinned to a commit or protected tag. The reusable workflow requests an OIDC token, the cloud provider validates its claims against a trust policy, and a short-lived access token is issued for the duration of the job. The job runs on an ephemeral runner that disappears afterwards, and a signed attestation records what was built and how.
Identity: stop storing cloud credentials
The single highest-value change is replacing long-lived cloud keys in repository secrets with OpenID Connect. A stored access key has no expiry, is readable by every workflow in scope, appears in every fork-and-clone mistake, and was exactly what attackers harvested in the 2025 incidents. With OIDC, the workflow asks GitHub for a signed JSON Web Token per job, and the cloud verifies it against a trust relationship you configured. GitHub documents that the resulting cloud access token is short-lived and valid only for the duration of the job.
Definition: centralize the pipeline, decentralize the trigger
Forty bespoke pipelines cannot be audited. The pattern that scales is a small library of platform-owned reusable workflows (build, test, scan, deploy) and thin caller workflows in each repository that supply inputs. The platform team changes the library once, and every caller inherits the change. The next section covers the mechanics and the limits.
Execution: hosted first, self-hosted where it earns its keep
GitHub-hosted runners remove an entire class of operational and security work, because each job gets a clean machine. Self-hosted runners make sense when jobs need private network reach (databases, internal registries, on-prem hardware), special hardware such as GPUs, or when large steady volume makes owning compute cheaper. If you go self-hosted on Kubernetes, use Actions Runner Controller with ephemeral runners, never long-lived pet machines.
Governance: policy as the default, not as a review comment
Organization-level settings decide which actions are allowed, what the default GITHUB_TOKEN can do, whether workflows may approve pull requests, and which runners a repository can reach. Put .github/workflows under CODEOWNERS so changes to pipeline definitions require review from a designated owner, a recommendation GitHub makes explicitly in its hardening guidance.
Deeper Analysis: OIDC Federation, Reusable Workflows and Token Hardening
The execution model in one paragraph
Events create workflow runs. A run contains jobs, and each job is scheduled onto one runner whose labels match runs-on. Steps within a job share a filesystem and environment, while jobs share nothing except what you explicitly pass through outputs, artifacts or caches. Artifacts persist files between jobs and after the run. Caches speed repeated work. This isolation boundary is the one that matters for security: the job is the unit that holds credentials, so keep credential-bearing steps in small dedicated jobs and keep untrusted input far from them.
How GitHub Actions OIDC works
When a job has the id-token: write permission, it can request a JSON Web Token from GitHub’s token service. The token carries claims describing exactly where the job came from. GitHub’s documentation lists, among others, sub, aud, iss, repository, repository_owner, repository_id, actor, event_name, environment, ref, sha, workflow, job_workflow_ref, run_id and runner_environment. The cloud provider fetches GitHub’s public keys, verifies the signature, then compares claims against conditions on the role you configured.

Figure 2: The OIDC exchange. The workflow never holds a stored cloud secret. Trust is established once in the cloud account, and each job proves its identity with a signed, short-lived token.
In Figure 2, the sequence is: the job requests a token with a chosen audience, GitHub signs and returns it, the job presents it to the cloud’s token service, the service validates signature and claims, and returns temporary credentials. The security of the whole scheme lives in step four, the claim conditions. A trust policy that only checks the issuer and audience accepts tokens from any repository on GitHub, which is a serious misconfiguration. Always constrain the subject or other claims.
Writing trust policies that actually constrain
The sub claim is the primary hook. In the classic format it looks like repo:octo-org/octo-repo:environment:prod for a job bound to a deployment environment, with variants for branches and pull requests. GitHub’s current documentation also states that repositories created after July 15, 2026 use a new immutable default subject format that includes owner and repository IDs, and that this format is not available on GitHub Enterprise Server. I did not find the exact new layout on the page I retrieved, so check the OIDC reference before writing policies that string-match sub. The reason for the change is sound: names can be reused after a repository is renamed or deleted, while numeric IDs cannot.
Three practical rules follow. First, bind production roles to a GitHub Environment, so that the token’s environment claim and the sub both require the deployment protection rules (required reviewers, wait timers, branch restrictions) to have passed. Second, for reusable-workflow-based deploys, also match job_workflow_ref, so only your platform’s deploy workflow at a protected ref can assume the role, not any workflow in the calling repository. Third, avoid wildcard subjects such as repo:my-org/* for anything beyond read-only, low-risk roles. If a team can create a repository in your organization, they can mint a token that matches that wildcard.
Here is a minimal AWS example. The workflow requests only what it needs and assumes a role using the official credentials action.
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod
permissions:
id-token: write # allow OIDC token request
contents: read
steps:
- uses: actions/checkout@<full-commit-sha> # pin by SHA
- uses: aws-actions/configure-aws-credentials@<full-commit-sha>
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-prod-deploy
aws-region: eu-west-1
- run: aws sts get-caller-identity
And the AWS IAM trust policy condition that pairs with it. The condition keys use the token.actions.githubusercontent.com provider prefix.
{
"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:octo-org/octo-repo:environment:prod"
}
}
}
On Google Cloud, the equivalent is a Workload Identity Pool and provider with an attribute condition, for example restricting assertion.repository_owner and assertion.repository, then granting a service account impersonation to the mapped principal. On Azure, you create a federated identity credential on an app registration or a user-assigned managed identity with the issuer, subject and audience. The three clouds differ in syntax but share the same shape: issuer, audience, and a claim-based condition. GitHub’s per-cloud “Security harden deployments” guides are the authoritative setup steps, and I recommend following them rather than copying snippets from blog posts, including this one, since console flows and parameter names evolve.
One more claim family deserves attention. GitHub’s documentation notes that custom repository properties can appear as repo_property_* claims when administrators enable them. That enables attribute-based access control: instead of listing repositories in a trust policy, you tag repositories with a property such as a data classification and write the cloud condition against it. That scales far better when you manage thousands of repositories.
Reusable workflows versus composite actions
Both are reuse mechanisms, and they are not interchangeable. A reusable workflow is a complete workflow with on: workflow_call, invoked at the job level with uses:. It runs as its own job or jobs, on its own runners, with its own permissions and its own secrets handling. A composite action is a bundle of steps invoked inside an existing job, sharing that job’s runner, filesystem and environment.
| Property | Reusable workflow | Composite action |
|---|---|---|
| Invoked | At job level with uses: |
As a step inside a job |
| Runner | Its own job and runner | The caller’s runner |
| Secrets and token | Passed explicitly or inherited | Everything in the caller’s job |
| Permissions | Can only narrow the caller’s | Same as the job |
| Best for | Whole pipeline stages, signing and deploy | Small repeated step sequences |
Table 1: Reusable workflows and composite actions differ mainly in trust boundary. Permissions only narrow down a call chain, and outputs flow back up.
GitHub documents several limits worth designing around. You can nest reusable workflows up to ten levels, and a single workflow file can call at most 50 unique reusable workflows, counting the whole nested tree. A called workflow can downgrade the GITHUB_TOKEN permissions it inherits from the caller but cannot elevate them, and in a nested chain each level must be equal or more restrictive. Environment variables set inside the called workflow are not visible to the caller, so you must declare explicit outputs. Reusable workflows are called at the job level, not from within a step.
The choice is mostly about the trust boundary. Use a reusable workflow when you want the platform team to own an entire pipeline stage with its own isolation, for example the release job that holds the OIDC permission. Use a composite action for small repeated step sequences, such as setting up a toolchain and cache, where sharing the job environment is the point. Because a composite action runs with all of the job’s secrets and token in reach, treat every composite action you adopt with the same suspicion as any third-party action.
A caller and callee pair looks like this.
# caller: .github/workflows/ci.yml in an application repo
name: ci
on: [pull_request]
permissions:
contents: read
jobs:
build:
uses: my-org/platform-workflows/.github/workflows/build.yml@<full-commit-sha>
with:
node-version: "22"
secrets: inherit # prefer explicit secrets where possible
# callee: my-org/platform-workflows/.github/workflows/build.yml
name: build
on:
workflow_call:
inputs:
node-version:
type: string
required: true
outputs:
artifact-name:
value: ${{ jobs.build.outputs.artifact-name }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ steps.meta.outputs.name }}
steps:
- uses: actions/checkout@<full-commit-sha>
- uses: actions/setup-node@<full-commit-sha>
with: { node-version: "${{ inputs.node-version }}" }
- id: meta
run: echo "name=app-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- run: npm ci && npm test
Prefer passing named secrets over secrets: inherit, because inheriting hands every secret visible to the caller to the callee and erodes least privilege. Versioning the library also matters: a caller pinned to a moving branch gets platform changes immediately, which is convenient and risky. Pin production callers to a commit SHA or an immutable release tag, and let an automated update tool open pull requests when the platform ships a new version.
GITHUB_TOKEN: least privilege as a default
Every job receives a GITHUB_TOKEN, an installation token scoped to the repository. GitHub recommends setting the default to read-only for repository contents at the organization or repository level, then granting write scopes per job. Declare a top-level permissions: contents: read in every workflow, and raise only on the job that needs it, such as pull-requests: write for a commenting job or packages: write for a publish job. Leaving permissions unspecified falls back to the repository default, which in older repositories may be broad read and write.
Trigger pitfalls: pull_request_target and script injection
The pull_request trigger from a fork runs with a read-only token and no secrets, which is safe by design. The pull_request_target trigger runs in the context of the base repository, with access to secrets and a write-capable token, so GitHub advises avoiding it unless necessary, preferring workflow_run for privilege separation, and never checking out untrusted pull request code in such a workflow. The classic mistake is a pull_request_target workflow that checks out github.event.pull_request.head.sha and runs a build script from it, handing a fork author code execution with your secrets.
Script injection is the sibling bug. Interpolating ${{ github.event.pull_request.title }} or a branch name directly into a run: block lets an attacker craft a title that closes your shell command and appends their own. The fix GitHub documents is to pass untrusted values through an intermediate environment variable, which the shell treats as data.
- name: Safe use of untrusted input
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Title is: $PR_TITLE"
Supply-Chain Security: Pinning, the tj-actions Lesson and Provenance
What happened in March 2025
CISA’s alert on CVE-2025-30066 describes the compromise of tj-actions/changed-files, a popular action that detects which files changed in a commit or pull request. According to CISA, the malicious version was live from roughly 00:00 UTC on March 12, 2025 until about 12:00 UTC on March 15, 2025, and secrets exposed included access keys, GitHub personal access tokens, npm tokens and private RSA keys, sometimes hidden in a double-encoded base64 payload printed to workflow logs. CISA also lists a related compromise of reviewdog/action-setup@v1 (CVE-2025-30154) on March 11, 2025, which may have enabled the first one, and added both CVEs to its Known Exploited Vulnerabilities Catalog on March 26, 2025. The fixed release of the first action is v46.0.1.
The mechanism matters more than the names. Workflows referenced the action by a version tag. A tag is just a movable pointer, so when an attacker obtained the ability to rewrite tags, every workflow that said @v35 or similar silently began running attacker code on its next trigger, with whatever secrets and permissions that job had. On public repositories, logs are world-readable, so printing secrets to the log was an exfiltration channel that needed no outbound network access at all.
Pin to a full commit SHA, then manage the update flow
GitHub’s hardening guide states that pinning to a full-length commit SHA is currently the way to use an action as an immutable release, because tampering would require a SHA-1 collision, and tells you to verify the SHA comes from the action’s own repository and not a fork. It also notes that a lockfile for workflow dependencies, where your repository can adopt one, would supersede SHA pinning. Tags, by contrast, can be moved or deleted if an attacker gains access to the repository.
Pinning has a cost: you take on the update burden. A comment such as # v4.2.1 next to the SHA keeps humans oriented. GitHub’s guide warns that Dependabot will not create alerts for actions pinned to SHA values, so you need another path for knowing when a pinned action has a vulnerability or a new release, for example version-update pull requests, a scanner that resolves SHAs to releases, or an internal mirror with review. Without that, you trade one risk (silent change) for another (silent staleness).

Figure 3: Defence in depth for the Actions supply chain. Each layer fails independently, so none is sufficient alone.
Beyond pinning, layer the controls shown in Figure 3. Use the organization policy that restricts which actions may run, allowing only GitHub-owned actions, verified creators or an explicit allow-list. Fork third-party actions you depend on into an internal organization after review, and pin your workflows to the fork. Keep secrets out of the jobs that run third-party code: separate the job that runs a linter or change detector from the job that holds deployment credentials, so a compromised helper action has nothing valuable to read.
Why OIDC reduces the blast radius, not the risk
The 2025 incidents leaked long-lived secrets. If your jobs only ever held a short-lived OIDC-derived token, a leaked token would expire within the job’s lifetime, as GitHub documents. That is a big improvement, but it is not immunity. A malicious step in the same job could use the token while it is valid, to perform whatever the role allows. Hence the earlier advice on narrow roles per environment, and on putting the token-bearing step in a job that runs only trusted, pinned code.
Artifact attestations and provenance
GitHub artifact attestations are signed claims about how an artifact was built. They bind an artifact to its workflow, repository, environment, commit SHA and triggering event, and can carry an SBOM. GitHub documents that attestations alone provide SLSA v1.0 Build Level 2, and that building in a reusable workflow shared across repositories helps reach Build Level 3 by isolating the build from the calling workflow. That is a strong reason for the reusable workflow architecture beyond convenience: the signing identity belongs to the platform’s workflow, not to an application team’s editable file.
Under the hood, GitHub uses Sigstore. Public repositories use the Sigstore Public Good Instance and a public transparency log. Private repositories use GitHub’s own Sigstore instance, which has no transparency log and federates only with GitHub Actions. Consumers verify with the GitHub CLI command gh attestation verify. The documentation includes a caveat I want to repeat: generating attestations provides no benefit unless someone verifies them, and an attestation is not a guarantee that an artifact is secure. It tells you where and how something was built, so your admission policy can decline artifacts that did not come from your approved pipeline. Sign releases, packages and manifests, not every test build. For the broader model, including in-toto and verification policy, see our SLSA and Sigstore architecture guide, and for what to do with SBOMs once attested, the CycloneDX versus SPDX triage guide.
A minimal attestation step in a publish job looks like the following. The permission names are those the attestation action documents at the time of writing; confirm them against the action’s README before use.
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # sign with a Sigstore certificate
attestations: write # store the attestation
steps:
- uses: actions/checkout@<full-commit-sha>
- run: make dist
- uses: actions/attest-build-provenance@<full-commit-sha>
with:
subject-path: dist/app.tar.gz
Self-Hosted Scale: Actions Runner Controller, Caching, Concurrency and Cost
Actions Runner Controller scale sets
Actions Runner Controller (ARC) is a Kubernetes operator that orchestrates and scales self-hosted runners. Per GitHub’s documentation, you install it with Helm charts published as OCI packages. The gha-runner-scale-set-controller chart deploys the controller manager, which reconciles the custom resources, and the gha-runner-scale-set chart creates an AutoScalingRunnerSet that defines each scale set. The controller calls the GitHub API to fetch or create the scale set and creates a listener pod.
The listener holds an HTTPS long-poll connection to the GitHub Actions service. When a job-available message arrives, the listener checks whether it can scale up, acknowledges the message, and updates the replica count of an EphemeralRunnerSet through the Kubernetes API. The EphemeralRunnerSet creates runner pods, and each pod’s EphemeralRunner controller requests a just-in-time configuration token for that runner. A job targets a scale set when its runs-on value matches the scale set’s name or labels. Because the listener connects outbound, your cluster needs no inbound webhook endpoint, which simplifies network security compared with older webhook-driven autoscalers.
Operational details from the documentation worth knowing: a failed runner pod is retried up to five times, a job that no runner accepts within 24 hours is unassigned by the service, and a runner pod is deleted after its job completes once the controller confirms it is safe. You can run ARC with a custom runner image, provided it contains the runner application launched via run.sh under /home/runner/, and for Kubernetes mode the container hooks under /home/runner/k8s.

Figure 4: ARC scale set data flow. The listener long-polls GitHub, scales an EphemeralRunnerSet, and each runner pod receives a just-in-time configuration and is deleted after one job.
Figure 4 shows the data flow end to end. Nothing in the loop requires an inbound connection to the cluster, and no runner outlives its job, which removes the persistence an attacker would need to plant a backdoor on a build machine.
A minimal installation, following the pattern in GitHub’s quickstart, uses two Helm releases. Chart locations and value names evolve between releases, so verify them against the current documentation and pin chart versions.
helm install arc \
--namespace arc-systems --create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
helm install build-pool \
--namespace arc-runners --create-namespace \
--set githubConfigUrl="https://github.com/my-org" \
--set githubConfigSecret.github_token="$GITHUB_PAT" \
--set minRunners=0 --set maxRunners=40 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
# workflow side: target the scale set by its install name
jobs:
build:
runs-on: build-pool
Prefer a GitHub App credential over a personal access token for the controller’s authentication in production, so the identity is not tied to a person who may leave. Set minRunners to zero for cost, or to a small warm count if cold-start latency matters, and cap maxRunners so a runaway matrix cannot exhaust cluster capacity or your cloud bill.
Securing self-hosted runners
GitHub is blunt here: self-hosted runners should almost never be used for public repositories, because anyone can open a pull request and run code on your machine. Use runner groups to limit which repositories can reach which runners, use just-in-time ephemeral runners so each job gets a clean one, and keep sensitive data off the machine. ARC’s design gives you the ephemeral part automatically.
Three Kubernetes-specific hazards deserve explicit design. First, container builds on a runner pod commonly want Docker-in-Docker, which usually needs privileged containers; that collapses the isolation between the job and the node. Consider rootless builders such as BuildKit in rootless mode, Kaniko-style builds or a dedicated build node pool with taints, so a compromised job can only reach disposable nodes. Second, runner pods should use a service account with no cluster permissions and a network policy that blocks access to the cloud metadata endpoint and to internal services they do not need. Third, if jobs on self-hosted runners also use OIDC, remember the runner_environment claim distinguishes hosted from self-hosted, and you can require it in trust policies for sensitive roles.
Caching strategy
The Actions cache accelerates dependency restores. GitHub documents a default limit of 10 GB per repository, adjustable by owners and administrators, with usage above the default billed. Entries not accessed for over seven days are removed, and when the repository exceeds its limit the oldest-accessed entries are evicted first, which can produce cache thrashing when many branches write large caches. Upload and download rates are limited per repository (the page cites up to 200 uploads and 1,500 downloads per minute).
Scoping rules have security consequences. A run can restore caches created on its own branch or the default branch, and pull request runs can also restore from the base branch, but not from sibling branches. Caches created by pull request runs are scoped to the merge ref. That scoping is a deliberate defence against cache poisoning from untrusted branches, but it also means a compromised default-branch workflow can poison a cache that every later run trusts. Treat the cache as untrusted input to release builds: for release jobs, either skip the cache or build from a clean lockfile-verified state.
Key design is where most teams lose time. The lookup order is exact key match, then prefix matches, then each restore-keys entry in order, and the most recently created cache wins on a partial match. A key that hashes your lockfile gives exact hits when dependencies are unchanged; a restore key on the OS and tool prefix gives a warm start when they change. Cache entries cannot be modified, so changed content needs a new key, and keys are limited to 512 characters.
- uses: actions/cache@<full-commit-sha>
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
Concurrency control and merge queues
The concurrency keyword limits a group of runs to one active at a time, with cancel-in-progress: true cancelling the older run. For pull request validation, group by workflow and pull request number so only the newest commit is tested. For deployments, do not cancel in progress by default, because killing a half-finished deploy can leave infrastructure inconsistent; queue instead.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
GitHub’s merge queue validates a pull request combined with the changes ahead of it before merging, which prevents the classic problem where two individually green pull requests break the main branch together. Workflows that act as required checks must also trigger on the merge_group event, otherwise the queue waits forever for a check that never starts; confirm event details in the current merge queue documentation before rollout. Merge queues trade latency for main-branch stability, so pair them with fast, parallel required checks.
A cost model you can reason about
GitHub’s billing documentation lists self-hosted runners and standard GitHub-hosted runners in public repositories as free of Actions minutes charges. For private repositories, each plan includes a monthly quota (the page lists 2,000 minutes for Free, 3,000 for Pro and Team, and 50,000 for Enterprise Cloud), with overage billed to the repository owner. At the time I read it, standard Linux 2-core x64 hosted runners were listed at $0.006 per minute, Linux 1-core at $0.002, Windows 2-core at $0.010 and macOS 3- or 4-core at $0.062. Artifact and cache storage are billed separately beyond included amounts, with the page listing $0.25 per GB-month for shared artifacts and Packages and $0.07 per GB-month for cache and custom images. Larger runners are always billed.
Those figures give you a first-order break-even. Suppose your organization consumes 400,000 Linux 2-core minutes a month. At the listed rate that is $2,400 a month before any included quota, a figure that is easy to compare against the fully loaded cost of a Kubernetes node pool, platform engineer time to run ARC, and idle capacity. This arithmetic is illustrative, using the listed rate and an assumed volume, not a measured result. The break-even moves with utilization: self-hosted fleets win when they are busy and lose when they sit idle, which is why scale-to-zero ARC pools and spot or preemptible nodes are the usual compromise. Pricing and plan quotas change; re-read the billing page before budgeting.
| Concern | GitHub-hosted | Self-hosted on ARC |
|---|---|---|
| Isolation per job | Clean VM every job | Clean pod if ephemeral, shared node risk |
| Private network access | Limited without extra setup | Native, in your VPC |
| Operations burden | Minimal | Cluster, images, autoscaling, patching |
| Cost shape | Per minute, pay as used | Fixed capacity plus per-use compute |
| Special hardware | Larger runners at extra cost | Any node type you own |
| Supply-chain exposure | GitHub patches images | You patch runner images |
Table 2: Choosing a runner model. The decision is usually driven by network reach and utilization, not by raw per-minute price.
Trade-offs, Gotchas, and What Goes Wrong
OIDC trust policies fail open. The most common real-world mistake is a trust policy that checks audience and issuer but not subject, or uses an overly broad wildcard. The result is that any repository, or any repository in your organization, can assume a privileged role. Review trust policies like firewall rules, and test them with a negative case: a token from a repository that should be denied.
Subject format changes break string matching. GitHub documents a new immutable subject format for repositories created after a 2026 date. Policies that hard-code the old pattern will not match new repositories, and policies that assume a repository name is a stable identity are weaker than ones that use IDs. Build your policy generation in code, not by hand.
Reusable workflow convenience becomes a single point of failure. One broken release of the platform library halts every pipeline in the organization. Version it, canary it on a few repositories, and give callers a pinned reference with a documented upgrade process. The flip side is also real: callers pinned too long miss security fixes.
SHA pinning without update automation rots. Pinned workflows will run stale actions for years unless you automate updates, and Dependabot does not alert on SHA-pinned actions according to GitHub’s guidance. Pin transitive dependencies too; a pinned action that internally calls an unpinned one inherits the weakness, which is how the reviewdog compromise could cascade into another action.
ARC capacity and cold-start surprises. Scale-to-zero saves money but makes the first job in a burst wait for a node and image pull. Large runner images and a cluster autoscaler adding nodes can add minutes. Pre-pull images, keep a small warm pool, and set resource requests so the scheduler can place pods. Remember GitHub unassigns a job that waits 24 hours for a runner, so a stuck pool eventually shows up as failed jobs rather than silently waiting forever.
Secrets in logs and artifacts. GitHub masks registered secrets in logs, but masking is string matching. Encoding, splitting or transforming a secret defeats it, as the base64 payload in the tj-actions incident showed. Artifacts uploaded from a job can also contain credentials such as cloud config files written by a login step. Scrub and scope.
Attestation theatre. Producing attestations nobody verifies adds cost and false comfort. Define the verification policy at deploy time, for example an admission controller that requires a valid attestation from your release workflow identity, before you count the control as real.
Cache poisoning and workflow_run privilege. A workflow_run workflow runs with base repository privileges and can consume artifacts produced by an untrusted pull request run. Treat those artifacts as untrusted data, never execute them, and validate before use.
Practical Recommendations
Start where risk reduction per hour of effort is highest, and sequence the rest so each step is reversible.
In the first month, remove standing credentials. Inventory every repository secret that holds a cloud key, replace each with an OIDC role bound to a GitHub Environment, and delete the old key rather than just ceasing to use it. Set the organization default GITHUB_TOKEN to read-only and add explicit top-level permissions to workflows. Turn on an allow-list policy for actions and require review of .github/workflows through CODEOWNERS.
In the second month, centralize. Build two or three platform reusable workflows (build and test, container publish with attestation, deploy), migrate the most common repositories first, and pin callers by SHA with automated update pull requests. Separate token-bearing deploy jobs from jobs that run third-party helpers.
In the third month, own your execution where it pays. Move jobs that need private network reach or have steady high volume to ARC scale sets with ephemeral runners, a dedicated tainted node pool and no cluster permissions. Keep everything else on hosted runners. Measure queue time, cache hit rate and cost per build before and after, and publish those numbers so the platform’s value is visible.
Checklist to adopt in order:
- Replace stored cloud keys with OIDC, one role per environment, claims constrained by
suband ideallyjob_workflow_ref. - Default
GITHUB_TOKENto read-only; elevate per job. - Pin third-party actions to full commit SHAs and automate updates; mirror critical ones internally.
- Avoid
pull_request_targetunless essential; never check out untrusted code in it; pass untrusted input via environment variables. - Publish platform reusable workflows; prefer named secrets over
secrets: inherit. - Use ephemeral, isolated self-hosted runners only where justified; no self-hosted runners on public repositories.
- Generate attestations for releases and verify them at deploy time.
- Set concurrency groups, cache keys and budgets, and review cost monthly.
Frequently Asked Questions
What is GitHub Actions OIDC and why use it?
GitHub Actions OIDC lets a job request a signed JSON Web Token describing its repository, environment, branch and workflow, then exchange it with AWS, Google Cloud, Azure or Vault for short-lived credentials. GitHub documents that the issued cloud token is valid only for the job’s duration. You avoid storing long-lived access keys as secrets, which removes the most commonly leaked credential type and lets you enforce access by workflow identity instead of by shared secret.
How do I secure the OIDC trust policy?
Constrain more than issuer and audience. Match the sub claim to an exact repository and environment, add job_workflow_ref when only a specific reusable workflow should deploy, and avoid organization-wide wildcards for privileged roles. Bind production roles to a GitHub Environment with required reviewers. Because GitHub changed the default subject format for repositories created after July 15, 2026, generate policies programmatically and consult the OpenID Connect reference for the exact format.
Reusable workflows or composite actions: which should I use?
Use reusable workflows for whole pipeline stages that need their own runner, permissions and trust boundary, such as build, sign and deploy. They are called at the job level, can nest up to ten levels deep and can only narrow the caller’s token permissions. Use composite actions for short repeated step sequences that should share the caller’s job. Composite actions run with the job’s secrets in reach, so review them as carefully as any third-party action.
Should I pin GitHub Actions to a commit SHA?
Yes for third-party actions, according to GitHub’s hardening guidance, because a full-length commit SHA is effectively immutable while tags can be moved, which is what enabled the March 2025 tj-actions compromise. Verify the SHA belongs to the action’s own repository, not a fork. Pair pinning with automated update pull requests, since Dependabot does not raise alerts for SHA-pinned actions, and prefer a workflow lockfile if your setup supports one.
How does Actions Runner Controller work?
ARC is a Kubernetes operator. A controller chart reconciles custom resources, and a scale set chart defines an AutoScalingRunnerSet. A listener pod long-polls GitHub over HTTPS, and when a job is available it raises the replica count of an EphemeralRunnerSet. Each runner pod gets a just-in-time configuration, runs exactly one job and is deleted. Jobs reach the scale set when runs-on matches its name or labels, and no inbound cluster endpoint is required.
Are self-hosted runners cheaper than GitHub-hosted runners?
Self-hosted runners incur no Actions minutes charge according to GitHub’s billing page, but you pay for compute, operations and idle capacity. They tend to win with steady, high-volume workloads and need for private network access, and lose when utilization is low or the operations burden is underestimated. Compare the listed hosted rate against your fully loaded node and engineering cost at your actual volume, and model scale-to-zero and spot capacity before deciding.
Further Reading
- SLSA and Sigstore: software supply chain security architecture for the provenance and signing model behind attestations.
- SBOM and VEX: CycloneDX vs SPDX for vulnerability triage for what to do with the SBOMs your pipeline produces.
- Terraform state management, drift detection and plan/apply pipelines at scale for running infrastructure pipelines on the OIDC pattern described here.
- Argo Rollouts vs Flagger: progressive delivery ADR for what happens after the pipeline ships an image.
- GitHub Docs: Secure use reference for GitHub Actions, the primary hardening guide.
- CISA alert on the tj-actions/changed-files supply chain compromise (CVE-2025-30066).
- GitHub Docs: OpenID Connect in Actions for claims and cloud trust setup.
By Riju — about
