skills/github-actions-matrix-patterns/SKILL.md
Use when designing matrix builds (OS x version x dimension), composing reusable workflows, gating jobs on conditions, doing OIDC to AWS/GCP/Cloudflare without long-lived secrets, sharding tests across runners, or fixing cache invalidation. Triggers: workflow_call between repos, fail-fast: false tradeoffs, exclude/include matrix surgery, environment protection rules, OIDC trust policy setup, concurrency cancellation on duplicate pushes, artifact reuse across jobs, conditional job matrices computed at runtime. NOT for GitLab CI / CircleCI / Jenkins (different yaml dialects), self-hosted runner administration, or local act dev.
npx skillsauth add curiositech/windags-skills github-actions-matrix-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Hello-world Actions are easy. Real CI gets stuck on three things: matrix expansion that runs the wrong cells, OIDC trust policies that reject your cloud auth, and cache keys that never invalidate (or invalidate on every commit). This skill catalogs those.
workflow_call.strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20, 22]
include:
- os: ubuntu-latest
node: 22
coverage: true # extra dimension only on this cell
exclude:
- os: windows-latest
node: 20 # known broken combination
fail-fast: true cancels the rest of the matrix on the first failure — fine for dev speed, bad for "tell me everything that's broken." Default to false for release CI.
workflow_call)# .github/workflows/test.yml
on:
workflow_call:
inputs:
node-version: { type: string, required: false, default: '22' }
coverage: { type: boolean, required: false, default: false }
secrets:
CODECOV_TOKEN: { required: false }
outputs:
coverage-pct:
value: ${{ jobs.test.outputs.coverage-pct }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
coverage-pct: ${{ steps.report.outputs.pct }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: ${{ inputs.node-version }} }
- run: pnpm test
- if: inputs.coverage
id: report
run: echo "pct=$(node scripts/coverage-pct.js)" >> "$GITHUB_OUTPUT"
Caller:
jobs:
call-test:
uses: ./.github/workflows/test.yml
with: { node-version: '22', coverage: true }
secrets: inherit
For cross-repo: uses: org/repo/.github/workflows/test.yml@v1 and pin to a tag, never @main.
GitHub mints a short-lived OIDC token. The cloud trusts it via a trust policy. No long-lived AWS keys in your secrets.
permissions:
id-token: write # mint OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-west-2
- run: aws s3 sync ./out s3://my-bucket/
AWS trust policy on github-deploy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main" }
}
}]
}
The sub condition is the security boundary. Tighten it to repo:org/repo:environment:production for additional protection from a malicious PR.
Cancel outdated runs when a new commit lands on a branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
For deploy jobs you don't want to cancel mid-flight, scope to PRs only:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Compute a matrix at runtime when the dimensions depend on the changed files:
jobs:
changed:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.set.outputs.packages }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: set
run: |
PKGS=$(git diff --name-only origin/main...HEAD | grep '^packages/' | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)
echo "packages=$PKGS" >> "$GITHUB_OUTPUT"
test:
needs: changed
if: ${{ needs.changed.outputs.packages != '[]' }}
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJson(needs.changed.outputs.packages) }}
steps:
- run: pnpm --filter @myorg/${{ matrix.package }} test
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-
key invalidates when the lockfile changes. restore-keys is the partial-hit fallback — restores the latest matching prefix.
For language toolchains, prefer the setup-* actions' built-in caching (actions/setup-node@v4 with: cache: 'pnpm'); they handle key construction.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pnpm test --shard=${{ matrix.shard }}/4
Most test runners (vitest, jest, playwright) accept --shard=K/N. Distribute by file count and rotate the assignment seed weekly to prevent a slow file always landing on shard 1.
jobs:
deploy-prod:
environment: production
runs-on: ubuntu-latest
environment: production triggers required reviewers, deployment branches policy, wait timer, and environment-scoped secrets. Use this for the production deploy step rather than baking approvals into the workflow logic.
@main for third-party actionsSymptom: Workflow that worked yesterday fails today; no commits to your repo.
Diagnosis: Floating tag — upstream pushed a breaking change.
Fix: Pin to a SHA: uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab. Or at minimum a major tag your security team has reviewed.
secrets.GITHUB_TOKEN for cross-repo writesSymptom: Workflow fails to push to another repo even with permissions: contents: write.
Diagnosis: GITHUB_TOKEN only has scope to the current repo.
Fix: Use a deploy key, fine-grained PAT, or GitHub App token (preferred for orgs).
exclude without includeSymptom: Matrix balloons to 30 cells; you exclude 25 of them.
Diagnosis: Inverting the polarity. Exclude is for trimming a small handful; include is for adding cells.
Fix: Build the small list explicitly with include only.
Symptom: Stale dependencies on every run; "works locally" recurring.
Diagnosis: Key uses only runner.os, no hash of the lockfile.
Fix: Always hashFiles('lockfile.path') in the key. restore-keys is fallback, not primary.
sub claim too permissiveSymptom: A fork PR triggers a deploy.
Diagnosis: Trust policy sub: repo:org/repo:* matches PR refs from forks.
Fix: Pin to specific refs: repo:org/repo:ref:refs/heads/main, or use environment claims: repo:org/repo:environment:production.
permissions: write-allSymptom: A compromised action exfiltrates a token with full repo write.
Diagnosis: Workflow inherits the default permissions, which historically were write-all.
Fix: Set top-level permissions: read-all and grant per-job: permissions: { contents: read, id-token: write }.
permissions: read-all; per-job grants minimal.environment: with required reviewers.concurrency set on PR workflows to cancel outdated runs.fail-fast: false on release CI matrices.secrets: inherit to forks.act local development — local-only quirks not worth conflating.pnpm-workspace-monorepo.dockerfile-build-cache-mastery for the Dockerfile/buildx side.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.