# GitHub Actions - Semantics and Caching
Reusable workflows, matrices, and secret mappings carry quiet rules that local YAML validation does not check. The cache fails in structural ways: reserve races keep cold keys cold, save keys that end in `run_id` never hit, and pull-request entries evict the nightly seeds. `actionlint` catches most workflow defects before a CI round-trip.
## Key points
- `vars.X` must be listed in `on.workflow_call.inputs` when a reusable workflow uses it. Local YAML validation stays silent. `actionlint` catches it.
- An action silently ignores inputs that its manifest does not list. `claude-code-action@v1` accepted `prompt` and `claude_args`, and ignored `direct_prompt` and `allowed_tools`. `actionlint` is the only static gate that catches it.
- Matrix values cannot be arrays. A list value arrives as a space-separated string, so `toJson(matrix.chunk)` embeds a quoted string and a downstream jq `sort` dies with exit 5. One root cause, several symptoms.
- A final root-level re-checkout deletes the nested checkout that a local composite action lives in. The action's post step then fails with `Can't find 'action.yml'`, even when all tests passed.
- Environment secrets on a reusable-workflow job do not override a literal empty `secrets:` mapping in the caller (actions/runner#4453). The job sees an empty value even though the environment holds the secret. Only `secrets: inherit` or explicit forwarding delivers the value. The mapping must be present on every hop of a nested call.
- Step-level parallelism (`background`, `wait`, `wait-all`) is orthogonal to job-level parallelism. It pays only when steps contend for different resources, such as a network-bound download overlapping CPU-bound setup. Two CPU-bound steps on a fixed-core runner gain nothing.
- The safe-to-background test: the step must write outside the git workspace, and nothing between the step and the join may consume its output. Steps that intuition flags as parallelizable can fail both checks.
- `actionlint` validates step keys against a set hardcoded in its Go parser, not against a downloadable schema. New step keys need an `actionlint` release. A marker-based skip re-includes the file automatically once support lands. The `-ignore` flag filters by message regex repo-wide and hides real typos.
- Measure before you optimize workflow wall-clock. The gating job and the configured timeout ceiling, not the obvious job, set the time-to-green. One survey made two wrong claims until measurement corrected it.
- A stopgap that turns a loud failure into a silent skip hides the problem. Misconfiguration must fail visibly.
- Cache reserve races keep cold keys cold. When a fan-out all misses the same key, one job reserves it and the rest fail with `Unable to reserve cache`. This is save-side contention, not key scoping.
- A setup action whose save key ends in `github.run_id` can never report a hit. One new entry per job per run evicts a shared cache pool within hours. Light jobs restore cold and cost more than they save.
- Pull-request cache saves are write-once. Each job writes a multi-gigabyte entry keyed by its own `run_id`, and only a later push of the same pull request restores it. LRU protects those entries and evicts the nightly default-branch seeds.
- `actions/cache` emits an empty `cache-hit` output on a miss, not `false`. Code that treats empty as "step skipped" drops cold misses from hit-rate metrics, which then read a constant 1.0.
- Cache keys reject commas. Hash comma-separated feature lists into the key, for example a SHA-256 fingerprint of `lua54,luajit`. The failure shows only in a real workflow run, not locally.
- CodeQL default setup writes `codeql-dependencies-*` caches on `pull_request` even when no workflow defines it. `actions/cache` with `lookup-only: true` still saves in the post step.
## How to apply
1. Run `actionlint` and `yamlfmt` locally before you push workflow changes.
2. List every `vars.X` in `on.workflow_call.inputs`. Verify each input name against the action's manifest.
3. Keep matrix values scalar. Do not pass a list-valued matrix input to `toJson()`.
4. Forward secrets through nested reusable-workflow calls with `secrets: inherit`, or forward each `${{ secrets.X }}` at every hop. Never write a literal empty `secrets:` mapping.
5. Build cache keys from stable, run-independent values. Hash comma-separated feature lists into a SHA-256 fingerprint, and never end a save key with `github.run_id`.
6. Read `cache-hit` as three states: `true`, `false`, or empty. Treat empty as a cold miss in metrics.
7. While a step key is unsupported, grep for `# actionlint:skip-file <reason>` and drop the matches from the `actionlint` argument list. Delete the marker when support lands.
## Observations
- [ci] A literal empty `secrets:` mapping in the caller hides an environment secret from a reusable-workflow job. #github-actions (source: agent sessions, 2026-10)
- [caching] A save key that ends in `github.run_id` never hits, and one new entry per job per run evicts the shared pool within hours. #github-actions (source: agent sessions, 2026-09)
- [caching] A fan-out that all misses the same key triggers cache reserve races. The losers fail with `Unable to reserve cache`. #github-actions (source: agent sessions, 2026-09)
- [ci] `actionlint` validates step keys against a hardcoded set in its parser, not a schema. New keys wait for a release. #actionlint (source: agent sessions, 2026-07)
## Relations
- relates_to [[resources/documentation/GitHub Actions - Workflow Security and Rulesets]] (same platform, adjacent workflow-contract defects)
- relates_to [[resources/documentation/CI - Failure Triage and Silent Non-Runs]] (silent skips hide misconfiguration from triage)
## Sources
Personal agent-session notes, February 2026 to November 2026