Intermediate GitHub Actions practice: dynamic pipelines and hardening
What this domain tests
The intermediate tier asks you to build pipelines that compute their own shape at runtime and to close the security holes that appear once workflows handle untrusted input. Items typically hinge on expression semantics, script injection, token scope, and action supply-chain pinning.
Expressions and dynamic matrices
- Comparisons are loose: differing types are coerced to numbers, so
'' == 0andnull == 0are true, and string comparisons ignore case. fromJSONturns a JSON string (often a job output) into an array or object;toJSONdoes the reverse for debugging.contains,startsWith,endsWith,format, andjoinare available;hashFilesreturns an empty string when nothing matches.- A matrix built with
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}fails the run if the array is empty, so always emit a fallback or guard the job.
Injection and privileged triggers
- Never interpolate
${{ github.event.* }}directly into arun:block. Assign it toenv:and reference"$VAR"so the shell treats it as data. pull_request_targetruns in the base repository with secrets and a write token. Checking outgithub.event.pull_request.head.shaand executing it is a well-known compromise path.- Pin third-party actions to a full commit SHA; tags are mutable. Dependabot can keep SHA pins current.
Debugging and observability
- Set the repository secret
ACTIONS_STEP_DEBUGtotruefor verbose step logs;ACTIONS_RUNNER_DEBUGcovers runner diagnostics. - Write Markdown to
$GITHUB_STEP_SUMMARYfor per-run reports, and emit::error file=app.js,line=10::messageto annotate a diff.
Worked example
A planner job emits a matrix, and a worker consumes it safely:
jobs:
plan:
runs-on: ubuntu-latest
outputs: {matrix: "${{ steps.set.outputs.matrix }}"}
steps:
- id: set
run: echo 'matrix=["api","web"]' >> "$GITHUB_OUTPUT"
test:
needs: plan
strategy: {matrix: {package: "${{ fromJSON(needs.plan.outputs.matrix) }}"}}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- env:
PKG: ${{ matrix.package }}
run: npm test --workspace "$PKG"
Reasoning: the matrix is data, not hard-coded; quoting "$PKG" keeps a hostile package name inert; fromJSON is required because the output arrives as a string.
Traps that cost marks
- Writing
if: github.event.issue.title == 'x'is fine, but using that title insiderun:is injection. id-token: writemust be granted explicitly; OIDC is unavailable otherwise.- Step
ifconditions readingsteps.<id>.conclusionbehave differently from.outcomeundercontinue-on-error. - A
hashFilescache key that silently returns''produces a shared, wrong cache instead of failing.