GitHub Actions
CI that takes 25 minutes gets ignored, and a workflow with a write-token running untrusted code gets owned. This skill builds pipelines against two hard constraints at once - a speed budget developers will actually wait for, and a security posture that assumes PR authors are hostile - because retrofitting either into a sprawling workflow file is far more painful than starting right.
Operating procedure
Step 1: Gather inputs
- Stack and package manager (locks the caching strategy).
- Current wall-clock time of the slowest required check, and the test-suite runtime.
- Monorepo or single package; deploy targets and who may approve prod.
- Any use of forked PRs or
pull_request_target today (security review trigger).
Step 2: Set the budgets
- PR feedback (all required checks) under 10 minutes wall-clock; under 5 is the mark of a healthy repo. Anything over 15 minutes changes developer behavior - people batch commits and stop running CI-first.
- No single job over 10 minutes; split or parallelize past that.
- Dependency-install step under 60 seconds on a warm cache; if
npm ci runs 3+ minutes every build, caching is broken.
- Cache hit rate above 90% on PR builds. Check the cache step's output over a week of runs; a low rate means the key is too specific or the cache is thrashing the 10GB-per-repo eviction limit.
Step 3: Structure jobs for parallelism
- Run independent jobs (lint, typecheck, unit, build) in parallel; use
needs only for real data dependencies, not for tidiness - every unnecessary needs serializes the graph.
- Shard test suites over ~5 minutes across matrix jobs.
- Use concurrency groups to cancel superseded runs on the same branch.
- Monorepo: trigger by changed paths (path filters or affected-detection like Turborepo/Nx) so a docs change does not run the mobile build.
Step 4: Caching
- Use the built-in cache in
setup-node/setup-python for dependencies - the biggest, easiest win.
- For custom caches, key on the lockfile hash with a
restore-keys prefix fallback, so a lockfile change still restores the nearest cache instead of starting cold: key os-node-${{ hashFiles('**/package-lock.json') }}, restore-key os-node-.
- Cache build outputs (Turborepo/Nx remote cache) to skip unchanged work entirely.
- Never cache things that are faster to recreate than to download (small artifacts), and never cache secrets or credentials into a cache readable by PR builds.
Step 5: Security hardening
- Pin third-party actions to a full commit SHA, not a mutable tag - tags can be moved to malicious code.
- Set least-privilege
permissions: at the workflow level (default contents: read) and widen per-job only where needed.
- Never echo secrets; pass via the
secrets context, not interpolated into run args where they hit logs and shell history.
- Treat
pull_request_target as loaded: it runs with a write token against untrusted fork code. Never check out and execute the PR's code under it; if you must, split into an unprivileged pull_request job plus a privileged job that consumes only validated artifacts.
- Forked PRs get no secrets - design required checks to pass without them.
Step 6: Deploys
- Gate prod behind environments with required reviewers and protection rules.
- Use OIDC to assume short-lived cloud roles instead of long-lived cloud keys stored as secrets.
- Gate deploys on green tests via
needs; never deploy from a workflow that skipped checks.
Step 7: Keep it maintainable
- Extract repetition into reusable workflows and composite actions once the same steps appear a third time.
- Fail loudly: no
|| true, no continue-on-error on required checks.
- Quarantine flaky tests to a non-blocking job and fix them; do not blanket-retry the suite to green.
Worked artifact: workflow skeleton
Copy, replace the [FILL] fields, delete jobs you do not need.
name: CI
on:
pull_request:
push: { branches: [main] }
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10 # hard stop; budget is well under this
steps:
- uses: actions/checkout@[FILL: full commit SHA]
- uses: actions/setup-node@[FILL: full commit SHA]
with: { node-version: [FILL], cache: 'npm' }
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/[FILL: total shards]
strategy:
fail-fast: false
matrix:
shard: [FILL: e.g. 1, 2, 3]
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@[FILL: full commit SHA]
- uses: actions/setup-node@[FILL: full commit SHA]
with: { node-version: [FILL], cache: 'npm' }
- run: npm ci
- run: npm run lint && npm run typecheck
deploy:
if: github.ref == 'refs/heads/main'
needs: [test, lint]
runs-on: ubuntu-latest
environment: production # required reviewers configured in repo settings
permissions:
contents: read
id-token: write # OIDC - no long-lived cloud keys
steps:
- uses: actions/checkout@[FILL: full commit SHA]
- uses: [FILL: cloud OIDC auth action]@[FILL: full commit SHA]
with: { role-to-assume: [FILL] }
- run: [FILL: deploy command]
Matrix testing across versions/OS follows the same pattern - matrix: { node: [FILL versions], os: [ubuntu-latest, macos-latest] } with fail-fast: false when every combination should report.
Deliverable
Produce the workflow file(s) plus a one-page pipeline report: measured PR feedback time vs the 10-minute budget, cache hit rate, the job dependency graph, and a security checklist result (SHA pinning, permissions, pull_request_target audit, OIDC).
Do NOT
- Do not pin actions to
@v4-style tags in anything with secrets or write permissions - pin the SHA.
- Do not run untrusted PR code under
pull_request_target or with elevated permissions.
- Do not serialize jobs with
needs for readability; each false dependency adds minutes.
- Do not blanket-retry flaky tests - a retried-green suite hides real races.
- Do not store long-lived cloud keys when OIDC is available.
- Do not let a job run unbounded - always set
timeout-minutes, or a hung job burns 6 hours of runner time.
Quality bar
- PR feedback under 10 minutes; every job has a timeout; cache hit rate above 90%.
- Every third-party action SHA-pinned; workflow-level permissions read-only; secrets never appear in logs.
- Prod deploys gated by environment protection and green checks, authenticated via OIDC.
- Forked-PR runs pass without secrets; monorepo paths filter correctly; superseded runs auto-cancel.
1---2name: github-actions3description: Designs and hardens GitHub Actions CI/CD - pipelines that hit a sub-10-minute PR feedback budget through caching and parallelism, with least-privilege security and safe deploy gates. Use when someone asks "why is my CI so slow", "how do I cache node_modules properly", "set up a deploy workflow", "is pull_request_target safe here", or wants a workflow written or reviewed. Do NOT use for provisioning the cloud infrastructure the pipeline deploys to - use terraform-expert instead; do NOT use for Vercel-specific deploy flows - use vercel-deploy-pipeline instead; for secret storage policy beyond CI, use secrets-hygiene.4---56# GitHub Actions78CI that takes 25 minutes gets ignored, and a workflow with a write-token running untrusted code gets owned. This skill builds pipelines against two hard constraints at once - a speed budget developers will actually wait for, and a security posture that assumes PR authors are hostile - because retrofitting either into a sprawling workflow file is far more painful than starting right.910## Operating procedure1112### Step 1: Gather inputs1314- Stack and package manager (locks the caching strategy).15- Current wall-clock time of the slowest required check, and the test-suite runtime.16- Monorepo or single package; deploy targets and who may approve prod.17- Any use of forked PRs or `pull_request_target` today (security review trigger).1819### Step 2: Set the budgets2021- PR feedback (all required checks) under 10 minutes wall-clock; under 5 is the mark of a healthy repo. Anything over 15 minutes changes developer behavior - people batch commits and stop running CI-first.22- No single job over 10 minutes; split or parallelize past that.23- Dependency-install step under 60 seconds on a warm cache; if `npm ci` runs 3+ minutes every build, caching is broken.24- Cache hit rate above 90% on PR builds. Check the cache step's output over a week of runs; a low rate means the key is too specific or the cache is thrashing the 10GB-per-repo eviction limit.2526### Step 3: Structure jobs for parallelism2728- Run independent jobs (lint, typecheck, unit, build) in parallel; use `needs` only for real data dependencies, not for tidiness - every unnecessary `needs` serializes the graph.29- Shard test suites over ~5 minutes across matrix jobs.30- Use concurrency groups to cancel superseded runs on the same branch.31- Monorepo: trigger by changed paths (path filters or affected-detection like Turborepo/Nx) so a docs change does not run the mobile build.3233### Step 4: Caching3435- Use the built-in cache in `setup-node`/`setup-python` for dependencies - the biggest, easiest win.36- For custom caches, key on the lockfile hash with a `restore-keys` prefix fallback, so a lockfile change still restores the nearest cache instead of starting cold: key `os-node-${{ hashFiles('**/package-lock.json') }}`, restore-key `os-node-`.37- Cache build outputs (Turborepo/Nx remote cache) to skip unchanged work entirely.38- Never cache things that are faster to recreate than to download (small artifacts), and never cache secrets or credentials into a cache readable by PR builds.3940### Step 5: Security hardening4142- Pin third-party actions to a full commit SHA, not a mutable tag - tags can be moved to malicious code.43- Set least-privilege `permissions:` at the workflow level (default `contents: read`) and widen per-job only where needed.44- Never echo secrets; pass via the `secrets` context, not interpolated into `run` args where they hit logs and shell history.45- Treat `pull_request_target` as loaded: it runs with a write token against untrusted fork code. Never check out and execute the PR's code under it; if you must, split into an unprivileged `pull_request` job plus a privileged job that consumes only validated artifacts.46- Forked PRs get no secrets - design required checks to pass without them.4748### Step 6: Deploys4950- Gate prod behind environments with required reviewers and protection rules.51- Use OIDC to assume short-lived cloud roles instead of long-lived cloud keys stored as secrets.52- Gate deploys on green tests via `needs`; never deploy from a workflow that skipped checks.5354### Step 7: Keep it maintainable5556- Extract repetition into reusable workflows and composite actions once the same steps appear a third time.57- Fail loudly: no `|| true`, no `continue-on-error` on required checks.58- Quarantine flaky tests to a non-blocking job and fix them; do not blanket-retry the suite to green.5960## Worked artifact: workflow skeleton6162Copy, replace the [FILL] fields, delete jobs you do not need.6364```yaml65name: CI66on:67 pull_request:68 push: { branches: [main] }6970permissions:71 contents: read7273concurrency:74 group: ${{ github.workflow }}-${{ github.ref }}75 cancel-in-progress: true7677jobs:78 test:79 runs-on: ubuntu-latest80 timeout-minutes: 10 # hard stop; budget is well under this81 steps:82 - uses: actions/checkout@[FILL: full commit SHA]83 - uses: actions/setup-node@[FILL: full commit SHA]84 with: { node-version: [FILL], cache: 'npm' }85 - run: npm ci86 - run: npm test -- --shard=${{ matrix.shard }}/[FILL: total shards]87 strategy:88 fail-fast: false89 matrix:90 shard: [FILL: e.g. 1, 2, 3]9192 lint:93 runs-on: ubuntu-latest94 timeout-minutes: 595 steps:96 - uses: actions/checkout@[FILL: full commit SHA]97 - uses: actions/setup-node@[FILL: full commit SHA]98 with: { node-version: [FILL], cache: 'npm' }99 - run: npm ci100 - run: npm run lint && npm run typecheck101102 deploy:103 if: github.ref == 'refs/heads/main'104 needs: [test, lint]105 runs-on: ubuntu-latest106 environment: production # required reviewers configured in repo settings107 permissions:108 contents: read109 id-token: write # OIDC - no long-lived cloud keys110 steps:111 - uses: actions/checkout@[FILL: full commit SHA]112 - uses: [FILL: cloud OIDC auth action]@[FILL: full commit SHA]113 with: { role-to-assume: [FILL] }114 - run: [FILL: deploy command]115```116117Matrix testing across versions/OS follows the same pattern - `matrix: { node: [FILL versions], os: [ubuntu-latest, macos-latest] }` with `fail-fast: false` when every combination should report.118119## Deliverable120121Produce the workflow file(s) plus a one-page pipeline report: measured PR feedback time vs the 10-minute budget, cache hit rate, the job dependency graph, and a security checklist result (SHA pinning, permissions, `pull_request_target` audit, OIDC).122123## Do NOT124125- Do not pin actions to `@v4`-style tags in anything with secrets or write permissions - pin the SHA.126- Do not run untrusted PR code under `pull_request_target` or with elevated permissions.127- Do not serialize jobs with `needs` for readability; each false dependency adds minutes.128- Do not blanket-retry flaky tests - a retried-green suite hides real races.129- Do not store long-lived cloud keys when OIDC is available.130- Do not let a job run unbounded - always set `timeout-minutes`, or a hung job burns 6 hours of runner time.131132## Quality bar133134- PR feedback under 10 minutes; every job has a timeout; cache hit rate above 90%.135- Every third-party action SHA-pinned; workflow-level permissions read-only; secrets never appear in logs.136- Prod deploys gated by environment protection and green checks, authenticated via OIDC.137- Forked-PR runs pass without secrets; monorepo paths filter correctly; superseded runs auto-cancel.