Sample teardown · The actual deliverable, redacted
Pipeline Teardown — [redacted client] · GitHub Actions
Verdict Five findings, two of them load-bearing: a yanked, unpinned action that a general LLM will wave through, and a secret referenced three ways that will break a 3am deploy on the next rotation.
actions/checkout@v3 is unpinned and yanked
The workflow pulls actions/checkout@v3 by moving tag. That tag has an active cache-poisoning advisory and has been yanked. A moving tag is a supply-chain hole regardless: whoever controls the tag controls your checkout step.
Pin to a full commit SHA, and record the version it corresponds to in a comment so future readers know what they are pinned to.
A general LLM will tell you @v3 is fine. It is not.
Applyable diff
- uses: actions/checkout@v3
+ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
The same secret is referenced three ways across two workflows
One deploy credential appears under three different names spread across deploy.yml and release.yml. On the next rotation, two of the three references go stale and the deploy fails, almost always at the worst possible time.
Consolidate to a single source of truth, ${{ secrets.DEPLOY_TOKEN }}, referenced consistently in both workflows, so rotation touches exactly one place.
No build cache key for the Go modules
Every build re-downloads the full module graph, roughly 15 minutes wasted per run, paid in runner time and developer patience.
Key an actions/cache step off go.sum so the cache invalidates exactly when dependencies change.
Applyable diff
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cache/go-build
+ ~/go/pkg/mod
+ key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-go-
Matrix fans out 12 jobs; only 3 are meaningful
The test matrix multiplies across OS and version axes into 12 jobs, but only three combinations exercise anything distinct.
The other nine are paid runner minutes producing redundant green checkmarks. Trim the matrix to the three meaningful combinations and drop the rest.
pull_request_target where pull_request suffices
The CI workflow triggers on pull_request_target, which runs with write tokens and repository secrets in the context of a fork's PR, a well-known privilege-escalation surface.
Nothing in this job needs that elevated context. Switch to pull_request and the attack surface closes without losing any function.
Durable artifacts you keep
A committed lint gate that enforces the verdict
Findings fade; enforcement persists. This workflow runs actionlint and zizmor on every change and fails the build when an action is referenced by anything other than a full commit SHA. Commit it once and finding 01 cannot quietly return.
name: ci-lint
on: [push, pull_request]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
- name: actionlint
uses: raven-actions/actionlint@3a24062651993d40ff222ab58bf2c557b89cd55b # v2.0.1
- name: zizmor (audit + SHA-pinning)
run: |
pipx install zizmor
zizmor --persona=auditor .github/workflows/
A note on scope: this gate is meant to complement the tooling you already run, not replace it. Keep zizmor in the loop; keep Depot or Octolense doing what they do well. Countersign points the gate; your existing stack keeps holding it.