GitHub Actions has quietly become the default CI/CD engine for the open-source world and a growing share of the enterprise. As of April 2026, the platform processes billions of workflow runs each month across 100+ million repositories, and GitHub’s January 2026 price cut of up to 39% on hosted runners has only accelerated adoption. If you have been putting off learning GitHub Actions because the YAML looked intimidating or the docs felt scattered, this tutorial is for you.
Over the next 12 steps you will build a complete, production-grade pipeline for a Node.js + TypeScript application: lint, test, build, security scan, container image, multi-environment deploy, and OIDC-based cloud authentication. We will use the latest 2026 features including the new Runner Scale Set Client, custom runner images that reached general availability in April 2026, artifact attestations, and the tightened security defaults from the 2026 Actions Security Roadmap. Every workflow file in this tutorial is copy-paste ready. Total reading time is roughly 45 minutes; a developer comfortable with Git and the command line can finish the hands-on portion in about three hours.
What Is GitHub Actions in 2026 and Why It Matters
GitHub Actions is an event-driven automation platform built directly into GitHub. Every push, pull request, issue comment, schedule, or external webhook can trigger a workflow defined in a YAML file under .github/workflows/. Each workflow is composed of jobs that run on a runner (a virtual machine or container), and each job is a sequence of steps that either execute shell commands or call a reusable action. That four-level hierarchy (workflow → job → step → action) is the entire mental model.
Three things changed in 2025-2026 that make this tutorial different from anything you might have read two years ago. First, GitHub-hosted runner prices dropped by up to 39% on January 1, 2026, while self-hosted runners began to cost $0.002 per minute starting March 1, 2026 to fund the Actions control plane. Second, custom runner images reached general availability in April 2026, allowing teams to bake dependencies into a versioned, pinnable VM image using the new snapshot keyword. Third, the Runner Scale Set Client entered public preview in February 2026, giving you platform-agnostic autoscaling for self-hosted fleets on Kubernetes, VMs, or bare metal with native multi-label support. We will touch on all three.
The competitive picture also matters. GitLab CI/CD, CircleCI, Buildkite, and Jenkins are all viable, but Actions wins on integration, marketplace breadth, and zero-config onboarding for any project already on GitHub. The Actions Marketplace currently lists more than 22,000 published actions and reusable workflows, and a recent Stack Overflow developer survey put GitHub Actions as the most-used CI tool by professional developers two years running.
Prerequisites and Tooling Versions
Before you start typing, install or verify the following. Versions listed are the minimums tested for this tutorial as of April 2026; newer minor releases will work without changes. If you are already a Node and Docker user, this is a 10-minute setup; if you are starting from scratch, plan on 30 minutes.
| Tool | Minimum Version | Why You Need It | Verify Command |
|---|---|---|---|
| Git | 2.40+ | Push code to GitHub, trigger workflows | git --version |
GitHub CLI (gh) | 2.50+ | Manage secrets, environments, PRs from terminal | gh --version |
| Node.js | 20 LTS or 22 LTS | Build and test the sample app | node --version |
| npm or pnpm | npm 10+ / pnpm 9+ | Dependency management | pnpm --version |
| Docker Desktop or Engine | 25.0+ | Build container images locally | docker --version |
| act (optional) | 0.2.65+ | Run workflows locally before pushing | act --version |
| An AWS, GCP, or Azure account | any | OIDC deploy step (Step 11) | cloud CLI of your choice |
You also need a GitHub account with a repository you own. The free GitHub plan includes 2,000 Linux runner minutes per month for private repos and unlimited minutes for public repos, which is more than enough to follow along. Authenticate the CLI now so later commands work without prompts:
# Authenticate gh and verify
gh auth login --web --scopes "repo,workflow,admin:org"
gh auth status
# Expected output:
# github.com
# Logged in to github.com account your-handle (keyring)
# - Active account: true
# - Git operations protocol: https
# - Token scopes: 'admin:org', 'repo', 'workflow'
Step 1: Create the Sample Repository and Project
We will work with a tiny Express + TypeScript REST API that exposes /health and /users/:id. The application is intentionally minimal so that you can focus on the pipeline rather than the business logic. Create the project locally first, then push to a new GitHub repository.
# Scaffold the project
mkdir actions-tutorial && cd actions-tutorial
pnpm init
pnpm add express
pnpm add -D typescript @types/node @types/express tsx vitest @vitest/coverage-v8
eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# Initialize TypeScript
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext
--outDir dist --rootDir src --strict --esModuleInterop
mkdir -p src tests
git init -b main
Drop the source code into src/server.ts:
import express, { Request, Response } from 'express';
export const app = express();
const port = Number(process.env.PORT) || 3000;
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
if (Number.isNaN(id) || id < 1) {
return res.status(400).json({ error: 'invalid id' });
}
res.json({ id, name: `user-${id}` });
});
if (require.main === module) {
app.listen(port, () => console.log(`listening on ${port}`));
}
Add a Vitest unit test in tests/server.test.ts that hits both routes with supertest, plus a basic .gitignore, ESLint config, and a Dockerfile. The complete sample repository is bundled in the linked starter kit at the end of this tutorial. Once everything compiles locally with pnpm exec tsc --noEmit and tests pass with pnpm exec vitest run, push to GitHub:
gh repo create actions-tutorial --public --source=. --remote=origin --push
Step 2: Write Your First Workflow File
Workflows live in .github/workflows/ and end in .yml or .yaml. The first one is the canonical “hello world” CI: install, lint, type-check, test on every push and pull request to main. Create the directory and the file:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Paste the following into ci.yml. Read it once before saving – every keyword is explained immediately afterward.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm exec eslint src tests
- name: Type check
run: pnpm exec tsc --noEmit
- name: Test
run: pnpm exec vitest run --coverage
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
retention-days: 7
Push the file and watch the run appear under the Actions tab within seconds: git add . && git commit -m "feat: add CI workflow" && git push. The default-deny permissions: contents: read block is one of the security defaults shipped in the 2026 Actions Security Roadmap and is a habit you should adopt on every workflow you write.
Step 3: Master Triggers, Filters, and Concurrency
The on: key supports more than 35 event types. The most useful in 2026 are push, pull_request, workflow_dispatch (manual button in the UI), schedule (cron syntax), workflow_call (reusable workflows), and the new merge_group for merge queues. Combine them with path and branch filters to keep cost down.
on:
push:
branches: [main, 'release/**']
paths-ignore: ['**.md', 'docs/**']
tags: ['v*.*.*']
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
schedule:
- cron: '17 4 * * 1' # Mondays at 04:17 UTC
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
default: staging
merge_group:
types: [checks_requested]
The concurrency block in Step 2 is one of the highest-use 6 lines in any pipeline. By keying on github.ref and setting cancel-in-progress: true, every new push to a branch cancels the previous still-running job, which on a busy team can cut your monthly minutes bill by 30-50%. For deploy workflows you usually want the opposite – cancel-in-progress: false – so a deploy never gets killed mid-flight.
Pitfall: PRs from Forks Run with No Secrets
By default pull_request from a fork runs read-only and cannot access your repository or organization secrets. This is a security feature, not a bug. If you genuinely need secrets in a fork PR (for example to upload a coverage report), use pull_request_target instead and pin the action versions you trust – but never check out the PR’s code in a pull_request_target job, or you have just handed the fork your secrets.
Step 4: Choose Hosted vs Self-Hosted vs Larger Runners
GitHub provides four runner classes in 2026, and picking the right one is the single biggest cost lever you have.
| Runner Class | Hardware | Linux Price (per minute) | Best For |
|---|---|---|---|
| Standard hosted | 4 vCPU / 16 GB / 14 GB SSD | $0.005 (was $0.008 before Jan 2026 cut) | Most CI jobs |
| Larger hosted (8/16/32/64 cores) | up to 64 vCPU / 256 GB | $0.016 – $0.128 | Monorepos, ML, native builds |
| Arm hosted (Linux) | 4-64 vCPU Cobalt 100 / Ampere | ~37% cheaper than x64 equivalent | Containers and Go/Rust builds |
| Self-hosted | Anything you provision | $0.002 control-plane fee (since Mar 2026) | GPU, on-prem data, custom deps |
Switch runner with one line: runs-on: ubuntu-24.04-arm for Arm, runs-on: ubuntu-latest-16-cores for a larger runner, or runs-on: [self-hosted, linux, gpu] with a label set for self-hosted fleets. The new Runner Scale Set Client (public preview, February 2026) lets you autoscale self-hosted runners on any infrastructure with native multi-label routing, replacing brittle homegrown autoscalers.
Step 5: Cache Dependencies and Build Outputs
Caching is where amateur pipelines turn into professional ones. The setup-node, setup-python, and setup-go actions all support a cache input that wraps actions/cache automatically – use it. For everything else (Docker layers, Cargo, Gradle, Bazel, Turbo, Nx, Vite), cache the directory directly with a key derived from the lockfile hash.
- name: Cache Turbo build
uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Cache Docker buildx
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: buildx-${{ github.sha }}
restore-keys: |
buildx-
The free cache quota is 10 GB per repository with a 7-day eviction policy. GitHub uses an LRU policy, so big-but-rarely-used keys get evicted first. A common mistake is keying purely on github.sha with no restore-keys fallback – that gives you a 0% hit rate on every commit. Always include at least one looser fallback key.
Step 6: Use Matrix Builds to Test 9 Combinations in Parallel
The matrix strategy expands a single job definition into a Cartesian product of variable values, with each combination running in parallel up to your account’s concurrency limit (20 concurrent jobs on free, 60 on Pro, 180 on Team, 500 on Enterprise). The example below runs the test suite on three Node versions and three operating systems – nine jobs from one definition.
jobs:
test:
strategy:
fail-fast: false
matrix:
node: ['20', '22', '24']
os: [ubuntu-24.04, macos-14, windows-2022]
include:
- os: ubuntu-24.04-arm
node: '22'
exclude:
- os: windows-2022
node: '20'
runs-on: ${{ matrix.os }}
name: Node ${{ matrix.node }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec vitest run
Two flags rescue you from runaway costs. fail-fast: false tells the runner to keep all matrix legs going even if one fails – without it the first failure cancels every sibling and you lose data on whether the bug is platform-specific. max-parallel: 4 caps concurrency on a single matrix to keep cheaper jobs from blocking the queue for higher-priority workflows.
Step 7: Build and Push a Multi-Architecture Container Image
Containers are the lingua franca of modern deploys. The docker/build-push-action in combination with docker/setup-buildx-action can produce a single multi-arch (amd64 + arm64) image, push to GitHub Container Registry (GHCR), and emit an artifact attestation that proves the image was built by your workflow on that commit.
jobs:
image:
needs: build-test
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,suffix=,format=short
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
The actions/attest-build-provenance action signs the image with a Sigstore Fulcio certificate tied to your repository’s OIDC identity. Anyone with the image digest can verify it later with gh attestation verify or cosign verify-attestation, which is now table stakes for any image you ship to production.
Step 8: Manage Secrets, Variables, and Environments
GitHub gives you four scopes for sensitive data: organization-level secrets, repository-level secrets, environment-level secrets, and the auto-generated GITHUB_TOKEN. The token is regenerated for every job and its permissions can be scoped per workflow with the permissions: key – always start with contents: read and add only what you need.
# Create a repo secret from the CLI
gh secret set DATABASE_URL --body "postgres://user:[email protected]/app"
# Create an environment with required reviewers
gh api -X PUT repos/:owner/:repo/environments/production
-f wait_timer=15
-F reviewers='[{"type":"User","id":12345}]'
-F deployment_branch_policy='{"protected_branches":true,"custom_branch_policies":false}'
# Add an environment-scoped secret
gh secret set AWS_ROLE_ARN --env production
--body "arn:aws:iam::111122223333:role/gh-actions-deploy"
Environments unlock deployment protection rules: required reviewers (up to 6), wait timers (up to 30 days), branch restrictions, and the new custom deployment protection rules that let an external service such as Datadog or PagerDuty approve or block a deploy via webhook. Reference an environment in a job and the rules fire automatically:
jobs:
deploy:
environment:
name: production
url: https://api.example.com
runs-on: ubuntu-24.04
steps:
- run: ./deploy.sh
Step 9: Authenticate to AWS, GCP, or Azure with OIDC (No Long-Lived Keys)
Storing static cloud credentials as secrets is a bad practice that GitHub has been actively deprecating. Since 2023, all three major clouds support OpenID Connect (OIDC) federation: GitHub mints a short-lived JWT for the workflow run, the cloud verifies it against a trust policy you create once, and the workflow gets temporary credentials with no secret material on disk.
# AWS example - assumes you've created an IAM OIDC provider
# and a role trusted by token.actions.githubusercontent.com
jobs:
deploy:
runs-on: ubuntu-24.04
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
role-session-name: gh-${{ github.run_id }}
- name: Deploy
run: |
aws s3 sync ./dist s3://my-app-bucket/
aws cloudfront create-invalidation
--distribution-id E1A2B3C4D5 --paths '/*'
The trust policy on the AWS role should restrict by repository, branch, and (ideally) environment using the JWT subject claim, for example repo:my-org/my-repo:environment:production. Skipping this restriction is the single most common OIDC misconfiguration; without it, any workflow in any repo of yours can assume the role.
Step 10: Build Reusable Workflows and Composite Actions
By the time you have three pipelines you will be copy-pasting the same checkout-setup-install steps everywhere. Stop. GitHub gives you two abstractions to factor that out: composite actions (a folder with an action.yml that bundles steps) and reusable workflows (a full workflow callable from another workflow with workflow_call).
# .github/workflows/_node-setup.yml (reusable workflow)
name: node-setup
on:
workflow_call:
inputs:
node-version:
type: string
default: '22'
outputs:
cache-hit:
value: ${{ jobs.setup.outputs.cache-hit }}
jobs:
setup:
runs-on: ubuntu-24.04
outputs:
cache-hit: ${{ steps.node.outputs.cache-hit }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- id: node
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
Call it from any workflow in the same org:
jobs:
ci:
uses: my-org/.github/.github/workflows/_node-setup.yml@v1
with:
node-version: '22'
Pin reusable workflows by tag (@v1) or commit SHA, never @main. The 2026 Security Roadmap is hardening defaults around third-party action pinning, and Dependabot’s github-actions ecosystem now opens PRs to bump those pins on every release.
Step 11: Add Security Scanning, SBOMs, and Dependabot
Five things every production repo should have wired up by the end of week one. Each is one short workflow file or one config file in .github/.
- CodeQL for SAST:
github/codeql-action/init+analyze, runs on push and weekly schedule. - Dependency Review on PRs:
actions/dependency-review-action@v4blocks PRs introducing vulnerable or non-compliant dependencies. - Trivy or Grype for container scanning:
aquasecurity/[email protected]withseverity: CRITICAL,HIGH. - SBOM generation:
anchore/sbom-action@v0emits SPDX or CycloneDX, attached as a release asset. - Dependabot: a 5-line
.github/dependabot.ymlwith ecosystems fornpm,docker, andgithub-actions.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule: { interval: weekly }
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: /
schedule: { interval: weekly }
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
groups:
actions:
patterns: ['*']
Group everything in the github-actions ecosystem so Dependabot opens one PR per week instead of twelve. The contents: write permission required by some scanners must be granted only to the dedicated job, not the whole workflow.
Step 12: Deploy with Environments, Approvals, and Rollback
Combine everything from the previous 11 steps into a single deploy workflow that runs on every tag. The workflow promotes the same image through staging and production environments, requires manual approval before production, and exposes a workflow_dispatch rollback trigger for the on-call engineer.
name: deploy
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
tag:
description: 'Image tag to deploy (for rollback)'
required: true
environment:
type: choice
options: [staging, production]
permissions:
contents: read
id-token: write
concurrency:
group: deploy-${{ inputs.environment || 'production' }}
cancel-in-progress: false
jobs:
staging:
if: github.event_name == 'push'
environment: staging
runs-on: ubuntu-24.04
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- run: aws ecs update-service --cluster app --service api
--force-new-deployment --image ghcr.io/${{ github.repository }}:${{ github.ref_name }}
production:
if: github.event_name == 'push'
needs: staging
environment:
name: production
url: https://api.example.com
runs-on: ubuntu-24.04
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- run: aws ecs update-service --cluster app-prod --service api
--force-new-deployment --image ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- name: Smoke test
run: curl -sf https://api.example.com/health
The environment: production declaration alone gates the job behind your reviewer rule and wait timer; nothing else is needed in the YAML. If the smoke test fails, the job fails, the deployment is marked failed in the Environments view, and your alerting (PagerDuty, Slack via the slackapi/slack-github-action) fires automatically.
Common Pitfalls and How to Avoid Them
- Pinning actions to
@mainor a floating tag. A compromised action update pushed tomainimmediately runs in your pipeline with whatever permissions you granted. Pin by full commit SHA in security-sensitive workflows. - Granting
permissions: write-all“just to be safe”. The default in any new repo created after February 2026 is read-only for a reason. Add only the scopes the job needs. - Forgetting
--frozen-lockfile/npm ci. Without it your CI silently upgrades transitive dependencies on every run, breaking reproducibility and occasionally introducing supply-chain risk. - Leaving the default 360-minute timeout in place. A stuck job at 6 hours of Linux runtime burns 360 × $0.005 = $1.80 per occurrence; on a busy repo this adds up fast. Always set
timeout-minutes. - Using
pull_request_targetwithactions/checkoutof the PR ref. This is the textbook GitHub Actions vulnerability – a fork PR can write code that runs with full secret access in the base repo’s context. - Caching with no
restore-keysfallback. A pure SHA-keyed cache has a 0% hit rate on every commit. Always supply a looser fallback. - Logging secrets accidentally. The runner masks secrets in logs but only if the value passed in is the same string stored in the secret. Base64-encoding before logging defeats the mask.
- Running on
ubuntu-latestin production pipelines. Thelatesttag flips to the next LTS without warning and breaks workflows. Pin toubuntu-24.04and bump intentionally.
Troubleshooting Guide: 8 Failures You Will Hit and Their Fixes
| Symptom | Likely Cause | Fix |
|---|---|---|
Resource not accessible by integration | Missing permissions: scope on the job | Add the specific scope (contents: write, packages: write, etc.) |
Error: Container action is only supported on Linux | Docker-based action on macOS or Windows runner | Switch the job to runs-on: ubuntu-24.04 or use a JS-based action |
| Workflow never starts on PR | YAML schema error – workflow not registered | gh workflow view shows registered workflows; check Actions tab for parse error |
HTTP 403 pushing to GHCR | Missing packages: write permission | Add to job-level permissions: |
| Cache miss every run | Cache key includes github.run_id or github.sha with no fallback | Use lockfile hash + restore-keys |
Process completed with exit code 137 | OOM kill – exceeded 16 GB on standard runner | Move to a larger runner (ubuntu-latest-8-cores+) or limit Node heap |
OIDC AssumeRoleWithWebIdentity 403 | Trust policy doesn’t match the JWT subject | Check token.actions.githubusercontent.com sub claim against role trust |
| Matrix job blows up to 50+ legs | Cartesian explosion from too many dimensions | Use include:/exclude: sparingly and add max-parallel |
Advanced Tips for Production Pipelines
Run Workflows Locally with act
The act CLI from nektos uses Docker to run your workflows locally, slashing the feedback loop from “push, wait 90 seconds, fail” to “10-second local re-run.” It is not a perfect substitute (no actions/cache, no real GITHUB_TOKEN), but it catches 80% of YAML and shell mistakes before they ever leave your laptop.
brew install act
act -j build-test -P ubuntu-24.04=catthehacker/ubuntu:full-22.04
--secret GITHUB_TOKEN="$(gh auth token)"
Use the New Custom Runner Images (GA April 2026)
If your job spends 90 seconds installing the same 2 GB of system dependencies on every run, bake them into a custom runner image instead. The new snapshot keyword in the workflow file builds an image from a GitHub-approved base, increments the version automatically on success, and pins all subsequent runs to that exact image.
jobs:
bake:
runs-on: ubuntu-24.04
snapshot:
name: my-org/runner-images/node22-pg16
version-strategy: minor
steps:
- run: |
sudo apt-get update
sudo apt-get install -y postgresql-16-postgis
npm install -g pnpm@9
Adopt the Runner Scale Set Client for Self-Hosted Fleets
If you run more than a handful of self-hosted runners, the Runner Scale Set Client (Go binary, public preview as of February 2026) replaces ARC-style Kubernetes-only autoscaling. It runs anywhere, supports multi-label routing natively, exposes Prometheus metrics, and integrates with the new Actions Data Stream for end-to-end observability.
Cost Control: GITHUB_TOKEN Scopes and Spending Limits
Set a hard monthly spending limit at the org level (Settings → Billing → Spending limits → set to $0 if you want a strict free-tier-only policy). Combine with the GITHUB_ACTIONS_RUNNER_TIMEOUT_MINUTES environment variable on self-hosted runners to prevent stuck jobs from running indefinitely.
Performance Benchmarks: Before and After Optimisation
The numbers below come from a typical 50-package monorepo (Turborepo, ~120 k LOC) running CI on a standard ubuntu-24.04 runner. They illustrate just how much wall-clock time you can claw back with the techniques in this tutorial.
| Optimisation | Wall Time Before | Wall Time After | Reduction |
|---|---|---|---|
Add pnpm cache: 'pnpm' | 4 min 12 s | 1 min 38 s | 61% |
| Add Turbo build cache | 1 min 38 s | 49 s | 50% |
| Move to Arm runner | 49 s | 41 s | 16% |
| Switch to 8-core larger runner | 41 s | 22 s | 46% |
concurrency.cancel-in-progress on PRs | n/a | −34% monthly minutes | – |
| Replace duplicate setup with reusable workflow | 900 lines YAML | 140 lines YAML | 84% LOC |
Comparing GitHub Actions to GitLab CI, CircleCI, and Buildkite
Pick the tool that fits your stack, not the one with the prettiest landing page. The shortest honest comparison fits in one table.
| Feature | GitHub Actions | GitLab CI | CircleCI | Buildkite |
|---|---|---|---|---|
| Native repo integration | GitHub only | GitLab only | Multi | Multi |
| Free tier (private) | 2,000 min/mo | 400 min/mo | 6,000 min/mo | Self-host only |
| Marketplace size | 22,000+ actions | ~1,500 templates | ~3,000 orbs | ~500 plugins |
| Reusable workflows | workflow_call (2022) | include + extends | orbs | pipeline upload |
| OIDC to AWS/GCP/Azure | Yes (all three) | Yes | Yes | Yes |
| Self-hosted runner cost | $0.002/min control plane | Free | Free | Free |
| Best for | Anyone on GitHub | GitLab-centric orgs | Heavy parallelism | Maximum flexibility |
Frequently Asked Questions
How much does GitHub Actions cost in 2026?
Public repositories get unlimited GitHub-hosted runner minutes. Private repositories get 2,000 free Linux minutes per month on the Free plan, 3,000 on Pro, 3,000 per user on Team, and 50,000 per user on Enterprise. After that, standard Linux is $0.005 per minute (cut from $0.008 in January 2026), Windows is 2x, macOS is 10x, and self-hosted runners cost $0.002 per minute as a control-plane fee since March 2026.
Should I use GitHub-hosted or self-hosted runners?
For 90% of teams, GitHub-hosted runners are the right answer – they require zero operational overhead and the January 2026 price cut closed most of the gap with self-hosted. Choose self-hosted when you need GPUs, on-prem network access, large persistent caches, or genuinely heavy parallelism that exceeds even the larger hosted runner classes. The Runner Scale Set Client makes self-hosting much less painful than it used to be.
What is the difference between an action, a workflow, and a job?
A workflow is the YAML file in .github/workflows/; it is what gets triggered by an event. A job is one of the top-level entries under jobs:; each job runs on its own runner. A step is a unit of work inside a job – either a shell command (run:) or a call to an action (uses:). An action is a reusable unit published in a separate repository or in the Actions Marketplace.
How do I share secrets between jobs?
You don’t share secrets between jobs – you read them in each job that needs them. Each job runs on a fresh runner with no shared filesystem or memory. If you need to pass a non-secret value, use job outputs; if you need to pass a secret-derived value, regenerate it in the consumer job using the same secret.
Why is my workflow not triggering on a fork PR?
It is – but the contributor’s first PR requires manual approval from a maintainer before workflows run. This is a security control to prevent malicious forks from spending your minutes or attempting to leak secrets. After the first approval, subsequent PRs from that contributor run automatically. The setting is at Settings → Actions → Fork pull request workflows from outside collaborators.
How do I roll back a bad deployment?
The pattern from Step 12 is the standard one: a workflow_dispatch trigger that takes a tag input and re-runs the deploy job against that older image tag. Combined with environment protection rules and image attestations, this gives you an audit trail of who rolled back what and when. For zero-downtime rollback you need orchestration on the deploy target side (ECS, Kubernetes, Cloud Run) – Actions only triggers it.
Are GitHub Actions secure enough for production?
Yes, when configured correctly. The 2026 Actions Security Roadmap shipped tighter defaults: read-only GITHUB_TOKEN in new repos, mandatory action review for first-time contributors, and platform-wide attestation support. Combined with OIDC for cloud auth, branch-protected environments, and pinned-by-SHA actions, you have the same security posture as a hand-rolled CI server with far less to maintain.
The Complete Working Project
Putting all 12 steps together, your repository should now contain four workflow files: ci.yml (Step 2), image.yml (Step 7), deploy.yml (Step 12), and _node-setup.yml (Step 10). Plus .github/dependabot.yml from Step 11 and a CodeQL workflow auto-added by GitHub when you enable code scanning. That is the complete production CI/CD setup most teams need; the only common addition beyond it is a release-notes workflow using release-drafter or conventional-changelog, both five-line drop-ins.
Iterate from here. The fastest way to get good at GitHub Actions is to copy a workflow from a project you admire (the React, Vite, and Astro repos all have outstanding ones), strip it down to what you understand, then add features one PR at a time. Every YAML file you read is education in someone else’s hard-won lessons.
Related Coverage
- GitHub vs GitLab 2026: The Leading DevOps Platform Comparison
- Bitbucket vs GitHub 2026: 15x User Gap and 4x Price Divide [Tested]
- How to Master Terraform: 12-Step AWS Tutorial with Modules and Remote State
- How to Get Started with Docker: Complete Beginner Tutorial
- How to Deploy Applications with Kubernetes and Helm: Complete Tutorial
- How to Master Pytest: 13-Step Tutorial with CI/CD and 90% Coverage
- Claude Code vs GitHub Copilot 2026: 80.8% vs 72.5% SWE-bench Comparison
- AI Coding Tools Guide
External references: official GitHub Actions documentation, the GitHub Marketplace, the Actions changelog, the Sigstore Cosign repository, and the OpenSSF for supply-chain best practices.
Nadia Dubois
Nadia Dubois is the AI & Innovation Editor at Tech Insider, where she tracks the rapid evolution of artificial intelligence, from foundation models to real-world enterprise deployment. She previously covered AI and startups for La Tribune and contributed to MIT Technology Review's European coverage. Nadia specializes in generative AI, AI regulation, and the intersection of technology and European industrial policy. She holds a dual degree in Computational Linguistics and Journalism from Sciences Po Paris.
View all articles