GitHub Actions
The dominant CI/CD platform in 2026 (~33% market share, JetBrains DevEcosystem 2024). Workflow architecture, runner types, OIDC auth to clouds, reusable workflows, supply-chain hardening, scaling patterns at monorepo size.
Overview
GitHub Actions in practice
GitHub Actions reached general availability with CI/CD in November 2019 and overtook Jenkins as the most-used CI/CD tool by 2022. For any team already on GitHub it is the path of least resistance, but the depth is non-trivial: hosted vs self-hosted runners, OIDC federation, reusable workflows, composite actions, marketplace supply chain, monorepo scaling. This topic is the operational reality.
- GitHub Actions — ~33%
- Jenkins — ~28%
- GitLab CI — ~21%
Architecture
Every workflow lives at .github/workflows/*.yml. GitHub's scheduler watches events and dispatches jobs to runners.
- Job — Runs as a fresh process; sharing state between jobs needs explicit artifact upload/download or a cache
- Step — Steps in a job share the runner filesystem and process; mix uses: actions/checkout@v4 calls with run: shell scripts
Pricing reality (2026)
- Hosted Linux — Free for public repos; $0.008/min for private (~3000 free min/month for Pro)
- Hosted Windows — 2x Linux
- Hosted macOS — 10x Linux
- Larger runners (4-vCPU, 8-vCPU, GPU), launched 2023 — Useful but expensive
- Self-hosted — GitHub does not charge for minutes; you pay for the VMs or pods
Why teams still pick something else
- Self-hosted Jenkins — Air-gapped, regulated, on-prem environments
- GitLab CI — GitLab shops
- Buildkite — Mega-monorepos needing fine control over agent fleets
- CircleCI — Where parallelism-by-test-timing is the default
> [!NOTE] GitHub Actions is the broadest fit but not always the deepest.
Three load-bearing concepts
- Layered reuse — Composite actions are inline shell-like steps usable across workflows. Reusable workflows (workflow_call) are whole workflow files callable as a unit with explicit inputs, outputs and secrets. Marketplace actions are versioned by tag or SHA. Composing these scales GitHub Actions across 30+ services
- OIDC federation — Long-lived AWS keys in repository secrets are the classic attack vector: compromise the runner, extract the key. With OIDC, GitHub mints a short-lived JWT per job, exchanged with AWS STS / Azure AD / GCP workload identity for a 1-hour role assumption. Required for any production-deploy workflow in 2026
- Pin by SHA — uses: actions/checkout@v4 follows whatever commit the mutable v4 tag points at. A compromised maintainer or stolen token retags v4 and every consumer's next run pulls it. Pin a 40-char SHA, comment the version, let Dependabot bump SHAs
> [!WARNING] The tj-actions/changed-files supply-chain attack of March 2025 was exactly the tag-retargeting failure mode, at scale.
Key concepts
- Workflow file
- Lives at .github/workflows/.yml. Single root, multi-job structure: The concurrency block is the single most common omission — without it, pushing twice in a row leaves both runs alive, racing each other. With it, the older run cancels and only the latest progresses.
- Runner types
- Three categories: GitHub-hosted: ubuntu-latest, ubuntu-22.04, windows-latest, macos-latest, plus larger runners (4-vCPU, 8-vCPU, GPU). Cold-start ~30s. Ephemeral (fresh VM per job). Best for: most workloads. Self-hosted (classic): you run an agent on a VM/box; jobs land on it. Persistent process; jobs run in the same env unless you explicitly clean up. Easy supply-chain footgun: a compromised job can plant a backdoor in the runner that the next job inherits. Best for: GPU, ARM, on-prem, network access. Actions Runner Controller (ARC): Kubernetes operator that spawns one ephemeral pod per job. Pod is destroyed on job end; no state persists. Solves the persistent-runner security problem. Best for: orgs with a Kubernetes platform; high-volume self-hosted needs. summerwind/actions-runner-controller (community v0.x) was deprecated 2023; the supported path is actions/actions-runner-controller (gha-runner-scale-set Helm chart, GA 2023).
- OIDC federation to AWS / GCP / Azure
- Replaces long-lived secrets with short-lived federated tokens. Per-job, GitHub mints a JWT with claims like repo:myorg/myrepo:ref:refs/heads/main. AWS IAM trust policy validates the JWT signature against GitHub's OIDC provider and accepts the role-assumption. The IAM role's trust policy restricts which repos / branches / environments can assume it — typical pattern: prod role accepts only main branch of one repo; staging role accepts PRs from the same repo. No long-lived credentials anywhere; rotation is implicit (each job gets a fresh 1-hour token).
- Reusable workflows (workflow_call)
- Full workflow files callable from other workflows. Defined with on: workflow_call: trigger: Consumed by: Nesting limit: 4 levels (caller → callee → callee → callee). Pin the called workflow by SHA or tag. Versioning a central .github repo with semver tags is the canonical pattern for org-wide reuse.
- Composite action
- Reusable steps as a unit, not a full workflow. Lives at action.yml in a repo or path: Consumed by uses: ./.github/actions/setup-stack with inputs. Lighter than a reusable workflow; runs in the caller's job (shares filesystem, env). Best for "common 4-step setup" patterns.
- Caching (actions/cache)
- Per-key dependency cache stored in GitHub-managed S3-like backend. 10 GB per repo limit; LRU eviction. The hashFiles pattern is essential: cache key changes only when lockfile changes. The restore-keys fallback uses an older cache when the exact match misses. setup-node, setup-go, etc. wrap actions/cache with sensible defaults; prefer those for language toolchains. Cache scope: cached entries are scoped to the branch creating them, with fallback to base branch. Caches written on main are usable from any PR; PR caches are private to the PR until merged.
- Concurrency control
- Without it: a contributor pushes 5 commits in 30 seconds, 5 workflow runs start, all 5 finish, you waste minutes and confuse status checks. With it: only the latest run survives. Use cancel-in-progress: false for deploy jobs (you don't want to interrupt a deploy mid-step). Use cancel-in-progress: true for PR validation jobs (always test the latest commit only). Concurrency groups also gate exclusive deploys: group: deploy-${{ inputs.environment }} on a manual-trigger workflow ensures one prod deploy at a time even with multiple PRs trying to ship.
- Matrix strategy
- Run the same job N times across combinations of inputs: That's 3 × 3 - 1 = 8 jobs in parallel. Set fail-fast: false to let all jobs complete even when one fails (default behaviour cancels siblings on first failure, which is rarely what you want for matrix). Use include: to add specific extra combinations not in the cross-product.
- Permissions block (GITHUB_TOKEN)
- Default GITHUB_TOKEN has repo-wide write permissions to most resources. Reduces every successful PR run to a potential supply-chain risk. Best practice: restrict per-workflow: Set the org-wide default to read on .github/settings.yml or in org settings; opt in to write per-workflow.
- Action pinning (SHA vs tag)
- Tags are mutable in git — the action repo maintainer can move v4 to point at a different commit. The tj-actions/changed-files supply-chain attack of March 2025 demonstrated this: an attacker compromised the maintainer's account, retagged stable releases to point at a malicious commit that exfiltrated repository secrets to a remote endpoint. Every consumer pinning by tag was instantly compromised. The fix: The 40-char SHA is content-addressed — if upstream re-tags, your reference still points at the original commit. Comment the human-readable version. Dependabot can update SHAs automatically with PRs. Per StepSecurity's guide: SHA-pin every third-party action. Tags are acceptable only for actions you own or trust unconditionally (and even then, SHA pin is safer).
- Recipe: monorepo build with Turborepo + path-filter
- Combines paths-filter (for non-Turborepo paths like infra/docs) and Turborepo's affected-only graph for the actual code: --filter='[origin/main]' tells Turborepo: "only run for packages changed since main." Combined with remote cache (Vercel, self-hosted), unaffected packages produce instant cache hits. A 12-minute "rebuild everything" pipeline becomes 1-3 minutes for a typical PR. Each unchanged package's build/test is skipped entirely.
- Recipe: Docker build with SLSA provenance + cosign signing
- Modern supply-chain-hardened image build: What you get: an OCI image with attached attestations for SLSA provenance (which commit, builder, inputs produced this image) and SPDX SBOM (every package and version inside). cosign signs the image keylessly using the OIDC token — verifiable later via cosign verify --certificate-identity-regexp='https://github.com/myorg/myrepo/.*'. Production deploys can require these attestations as a precondition (Kyverno admission policy in K8s).
- Recipe: debug a stuck workflow (canceled, queued, no-show)
- Common failure modes and how to triage: Workflow shows "Queued" indefinitely. Hosted runner exhaustion or self-hosted runners offline. Check Actions → Runners in repo/org settings — are runners reporting "Idle"? For hosted, check status.github.com for the Actions service. For self-hosted/ARC, check controller logs: kubectl logs -n actions-runner-system deployment/arc-controller-manager. Workflow says "Cancelled" with no error. Almost always a concurrency block — a newer push canceled this run. Look at concurrency: { cancel-in-progress: true } in the workflow. Also check for branch deletion (deleting a branch cancels all in-flight runs on it). Workflow runs but skips all jobs. Check the if: conditions. Common gotcha: if: github.event_name == 'push' on every job, but the trigger was pull_request. Fix by either adjusting the conditions or the on: triggers. Job stays "Pending" past 30 minutes. For hosted: bumped against concurrent-job limits (20 for free, 60 for Pro, 180 for Team, 500 for Enterprise). For ARC: kubectl describe pod -n runners on the pending pod — usually scheduling failures (no nodes, resource requests too high, taint/toleration mismatch). Workflow exits with "Process completed with exit code 1" and no further detail. Step output was suppressed because of - run: command > /dev/null or because the action swallowed the error. Add ACTIONS_STEP_DEBUG: true to repo secrets and re-run from the Actions UI to get verbose step traces. Composite action returns wrong outputs. Composite actions need explicit outputs declaration; assigning to $GITHUB_OUTPUT in a step doesn't propagate without:
- Recipe: required-reviewer production deploy via Environments
- Use GitHub Environments to gate production with human approval: Step 1 — create the environment in repo settings → Environments → New "production" → Required reviewers (designate 2 people) → Wait timer (5 min) → Deployment branches: only main. Step 2 — workflow references the environment: Step 3 — IAM trust policy on gha-deploy-prod scopes by environment claim: Now: PR merges to main → workflow reaches deploy-prod job → pauses with "Awaiting approval" → designated reviewer clicks Approve in the Actions UI → 5-minute wait timer → deploy proceeds with the environment-scoped IAM role. Even compromised-main can't auto-deploy without human approval. Compromised IAM role can't be assumed from any other branch (the sub claim won't match).
When to use it
- Designing CI/CD for a new service or migrating off Jenkins/CircleCI/Travis
- Justifying ARC (Actions Runner Controller) on Kubernetes vs scaled hosted runners
- Hardening an existing GHA setup that pins actions by tag and uses long-lived AWS keys
- Scaling CI for a monorepo where 30 minutes of GitHub-hosted minutes per PR is unaffordable
- Architecting reusable workflows for an org where 50 service repos all build Node apps
Interview questions
- Walk through GitHub Actions runner types and when you'd pick each.
- Show me how OIDC federation to AWS works and why it replaces long-lived keys.
- How do you scale GitHub Actions for a monorepo where every PR triggers 30 minutes of CI?
- Walk through hardening a production GitHub Actions setup against supply-chain attacks.
- Compare GitHub Actions, Jenkins, and GitLab CI — when do you pick which?
- Show me a "production-grade" GitHub Actions workflow — what would you put in it?
- Explain the key GitHub Actions trigger events and how path filters prevent wasted CI runs.
- How does caching work in GitHub Actions, and how do you share data between jobs?
Sign up free to read the full topic
More DevOps topics
Start free on Cariara · Coding problems · System design studio