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.

01 Critical

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
02 High

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.

03 High

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-
04 Medium

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.

05 Medium

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.

.github/workflows/ci-lint.yml
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.

Reviewed by Paul Julius, CITCON organizer, on 2026-06-05.
I stake my name on this verdict.

Signed · dated · provenance-stamped · config-only

The advisory landscape drifts. This verdict is current as of its date; the SHA pins and the enforcement gate keep it from silently rotting. When the landscape moves materially, the dates above tell you what to re-check.