| name | github-actions |
| description | 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".
|
GitHub Actions Operational Guide
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.
When to use
Load when the question is about:
pull_request_target / workflow_run / fork-PR secret leakage / pwn requests
- Expression injection (
${{ github.event.* }} in run: blocks)
- OIDC trust setup for AWS / GCP / Azure (cloud creds without long-lived secrets)
- Concurrency groups, cancel-in-progress semantics, queued-runs behavior
- Matrix
include/exclude edge cases, fail-fast, max-parallel
- Composite action vs reusable workflow design choice
- Cache scope (default-branch fallback, PR merge-ref isolation, cross-OS keys)
- Artifact v4 / cache v4 breaking changes (immutability, separate save/restore)
- Runner image pinning (
ubuntu-latest rollover) and deprecation timing
GITHUB_TOKEN permissions hardening
- Self-hosted runner security (ephemeral, ARC, JIT)
Do 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.
Trigger security: pull_request vs pull_request_target
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.
- The pwn-request pattern:
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.
- Safe pattern when you must touch PR content: two-workflow split.
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'.
Expression injection
OIDC federation (no long-lived cloud secrets)
- Required permission at workflow or job:
permissions: { id-token: write, contents: read }. Without id-token: write, the runner cannot mint the OIDC JWT.
- Subject claim formats (the
sub field cloud trust policies key on):
repo:OWNER/REPO:ref:refs/heads/BRANCH — branch-scoped
repo: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-scoped
- Trust policy must constrain
sub. 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:
aws-actions/configure-aws-credentials@v4 with role-to-assume (ARN) + aws-region. Audience defaults to sts.amazonaws.com. Calls AssumeRoleWithWebIdentity.
- GCP:
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:
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.
- Custom claims (org-scoped): repo properties prefixed
repo_property_* can be added to the JWT for fine-grained policies.
Concurrency
- Workflow-level
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).
- Job-level
concurrency: for finer-grained per-deploy serialization (e.g., one production deploy at a time across all branches: group: deploy-prod).
- Common pattern for deploys:
cancel-in-progress: false + group on environment to serialize without losing runs.
- Concurrency groups do not span repos or workflows unless you make the group key match.
Matrix semantics
strategy.matrix.OS: [ubuntu, macos] × node: [18, 20] produces the cross product (4 jobs).
include: has two distinct behaviors:
- If every original-key field in the include entry matches an existing combination, the entry adds extra fields to that combination (no new job).
- If any field does not match an original key/value, the entry adds a brand-new combination.
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).
- Hard cap: 256 jobs per matrix per workflow run.
- Matrix outputs collapse: if multiple matrix jobs write
outputs.X, the last writer wins. Use unique output names (outputs.${{ matrix.os }}_result) or write to artifacts.
Composite actions vs reusable workflows
- Composite action (
action.yml, runs.using: composite, runs.steps: [...]):
- Runs as a sequence of steps inside the calling job on the caller's runner
- Cannot access caller secrets unless explicitly passed as
inputs: (no secrets: inherit)
- Can call other actions; can use
shell: per step
- No
runs-on: — inherits from caller
- Reusable workflow (
.github/workflows/X.yml, on: workflow_call):
- Called via
jobs.<id>.uses: ./.github/workflows/X.yml@SHA (same repo) or OWNER/REPO/.github/workflows/X.yml@REF (cross-repo)
- Runs as separate job(s) on its own runner — distinct from caller
secrets: inherit passes all caller secrets implicitly (only within same org/enterprise)
- Environment secrets cannot pass through —
workflow_call does not support environment: at the trigger level
- Max 10 levels of nesting (top caller + 9 reusables); loops forbidden
- Pick composite when you need to splice steps into an existing job (e.g., setup-language wrapper). Pick reusable when you want a separately-scheduled, environment-gated job.
Caching (actions/cache@v4)
key: is exact. restore-keys: is an ordered list of prefixes to fall back to.
- Lookup order: exact
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.
- Scope rules:
- Default-branch caches are visible to feature branches (write to
main, read from anywhere).
- Feature-branch caches are NOT visible to
main or sibling branches.
- For PRs, also visible: caches from the base branch (including across forks).
- Cross-OS caches not interchangeable by default. Always include
${{ runner.os }} in the key, or set enableCrossOsArchive: true (opt-in, Windows-originated only).
- Limits: 10 GB per repo, LRU eviction, 7-day inactive eviction regardless of size.
- v4 split:
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.
- Common mistake: caching
node_modules instead of ~/.npm. The latter restores faster because npm reuses unpacked content. For Yarn, cache ~/.cache/yarn.
Artifacts (actions/upload-artifact@v4 / download-artifact@v4)
- v4 is immutable per-name within a workflow run — uploading to the same name twice fails rather than merging. Each artifact is now individually addressable via the API immediately after upload.
- v4 is incompatible with v3 — you cannot mix versions across upload and download.
- No automatic merge across jobs. To combine: upload with distinct names (
logs-${{ matrix.os }}), then actions/download-artifact@v4 with pattern: + merge-multiple: true, OR use actions/upload-artifact/merge@v4 after the matrix completes.
- 500 artifacts per job cap.
retention-days: overrides the repo default (90 days max).
Runner images and pinning
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.
- Active GA images: at most 2 GA + 1 beta per OS family. Old images get a deprecation notice + scheduled brownouts (failing runs at intervals) before final removal.
- Image changelog at
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.
Self-hosted runners
- Persistent runner (default
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.
- Ephemeral runner (
config.sh --ephemeral): processes exactly one job, then auto-deregisters. Required for any runner exposed to fork PRs.
- JIT registration: REST
POST /repos/{o}/{r}/actions/runners/generate-jitconfig returns a single-use config token; runner registers, runs one job, exits. Stateless by construction.
- ARC (Actions Runner Controller) for k8s: scale sets, JIT, autoscaling. The reference implementation; recommended over the legacy controller.
- Job assignment race: a runner shutting down may still receive a job. Ephemeral + JIT is the only fully-safe model.
GITHUB_TOKEN permissions
- Default scopes are org-policy controlled: either permissive (read+write across most categories) or restricted (
contents: read only, all else none). Check Settings → Actions → Workflow permissions.
- Override per-workflow or per-job with the
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).
- Common 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.
- The
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.
Secrets, variables, and masking
- Secrets are masked one-way in logs (any matching substring becomes
***). Variables are not.
- Scopes: organization > environment > repository. Environment secrets are only available to jobs that declare
environment: NAME.
${{ secrets.X }} and ${{ vars.X }} evaluate server-side before the runner sees them; logs of the rendered YAML never contain the value.
- Mask runtime values:
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.
- Multi-line masking: each line must be masked individually, or all lines fed through a single masked variable.
- Outputs cannot be set to a masked value (the runner refuses to expose them).
Environments and protection rules
environment: NAME on a job gates execution behind protection rules.
- Required reviewers: up to 6 people/teams. Only one needs to approve.
- Wait timer: minutes value, no documented hard cap (formerly capped at 30 days).
- Deployment branches: restrict which refs can deploy via patterns; alternative is "selected branches and tags" with explicit allowlist.
- Use environments for gated production deploys instead of
if: github.actor == 'X' — actor checks are bypassable in some forked-PR contexts and offer no audit trail.
Marketplace action security
- Pin to commit SHA, not tag —
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).
- Dependabot's
package-ecosystem: github-actions updater understands SHA pinning and proposes upgrades with the resolved tag in the PR body.
- The 2024-2025
tj-actions/changed-files and reviewdog/action-setup compromises propagated through tag re-points; SHA-pinned consumers were unaffected.
- For supply-chain hardening,
step-security/harden-runner provides egress filtering and tampering detection on Linux runners.
Triggers — non-obvious semantics
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.
Expressions — operational quick-ref
- Functions:
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.
- Status:
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.
- Operators:
==, !=, <, <=, >, >=, &&, ||, !. String comparison is case-insensitive. == does loose type coercion to numbers.
- Falsy values:
false, 0, -0, "", null. Missing context fields evaluate to null (not error).
- Object filter
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.
Limits (current values to plan around)
- Job execution: 6 hours on GitHub-hosted runners; 5 days on self-hosted.
- Workflow run total time: 35 days including queue + approvals.
- Matrix: 256 jobs/run.
- API: 1,000 req/hour for
GITHUB_TOKEN per repo (15k Enterprise Cloud).
- Concurrent jobs: 20 (Free) / 40 (Pro) / 500 (Enterprise) total across the account; larger runners up to 1,000.
- Trigger rate: 1,500 events / 10 sec / repo before throttling; 500 workflow runs / 10 sec / repo before queue blocks new triggers.
- Artifacts: 500 per job. Cache: 10 GB per repo.
Debugging
- Repository secret
ACTIONS_STEP_DEBUG=true enables ::debug:: log output (per-step expression resolution, env vars).
- Repository secret
ACTIONS_RUNNER_DEBUG=true enables runner internals (network, file I/O).
- Workflow commands:
::group::TITLE / ::endgroup:: for collapsible blocks; ::error file=path,line=N::msg, ::warning::, ::notice:: create annotations attached to commits.
- Re-run with debug logging from UI: enables both above for one run only.
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.
Authoritative references
Official GitHub docs (docs.github.com/en/actions):
Action repos:
Security research:
Guardrails
Before recommending a workflow change that touches secrets, triggers, or write-scoped tokens:
- Quote the exact trigger and
permissions: scope being granted.
- State whether the workflow consumes any attacker-controlled input (
github.event.* from PR/issue/comment).
- If
pull_request_target or workflow_run is involved, verify there is no checkout-then-execute of PR-controlled content.
- For OIDC, name the cloud
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.