Skip to content

Commit 860552b

Browse files
doublegateclaude
andcommitted
ci(security): harden checkout credentials, pin actions, fail-closed tag check
Three supply-chain items. The first two carry over from the sibling project's `v2.2.2` audit (logged in the lockstep checklist but never scoped to a rung); the third was raised by CodeRabbit reviewing #275 and declined there because pinning only the new job would have left the repo inconsistent while claiming to have addressed it. ## `persist-credentials: false` on 15 of 17 checkouts `actions/checkout` writes the workflow `GITHUB_TOKEN` into `.git/config`, where anything the job then executes from the checked-out tree -- build scripts, proc macros, test binaries, `scripts/*.sh`, the MkDocs build -- can read it. On a pull request that tree is by definition unreviewed code, and nearly every job here compiles or runs it. Highest exposure was not a `ci.yml` job but `web.yml`'s `build`, whose workflow-level `permissions:` grant `pages: write` + `id-token: write` while it runs `trunk`, `cargo doc`, `pip install`, and `mkdocs build`. Audited per site rather than applied blanket, and that distinction mattered: **`release-auto.yml`'s `prepare` job genuinely needs the credential.** It creates an annotated tag and `git push origin "$TAG"`, which authenticates through exactly that token. The sibling's audit concluded no job needed it -- that conclusion does NOT transfer here, and a blanket sweep would have silently broken every future release. It is now the only checkout in the repository that keeps the token, with the reason recorded inline so the exception reads as a decision. Its exposure is bounded by its trigger: `workflow_run` on a completed CI run of `main`, never a pull request, so the tree it executes is already-reviewed code. `antigravity-review.yml` already had the setting and is unchanged. ## The release-tag check now fails closed It was `git ls-remote --exit-code --tags origin "refs/tags/${tag}"`, which collapses THREE outcomes into two -- tag present (0), tag absent (2), and lookup failed (non-zero, various) -- reading any non-zero as "absent". A transient network blip, an auth hiccup, or a rate limit therefore sent an ALREADY-RELEASED version down the `should_release=true` path, re-tagging a release `to-dos/ROADMAP.md`'s ceremony treats as immutable. Now `gh api git/matching-refs/tags/<tag>`, chosen over `git/ref/tags/<tag>` because it answers "absent" with HTTP 200 and an empty array, so a genuine miss can never be confused with an error and no error-body parsing is needed. That endpoint matches by PREFIX, so the exact ref is compared in `jq`. Verified necessary rather than assumed: against this repository, `v1.2` prefix-matches SEVEN real tags while exact-matching none, so a naive `length > 0` would report an unreleased version as already released. Exercised all four cases against the live API -- existing tag, absent tag, prefix-only match, lookup failure. The step had no token of its own (it previously used only plain `git`), so `GH_TOKEN` is added to it; `gh` errors out without one rather than falling back to an ambient credential. ## All 34 action references pinned to commit SHAs Across all 8 workflows plus the composite `rust-setup` action. An upstream tag move could previously change what CI executes with no repository review. Each pin carries its original tag as a trailing comment so intent stays readable at a glance. Also aligned one `actions/upload-artifact@v4` -- introduced with the fuzz job in the previous commit -- to the `@v7` the rest of the repo already used, before pinning it. ## Verification `actionlint` reports the SAME three findings before and after (a self-hosted runner label, two pre-existing shellcheck notes), confirmed by linting the HEAD copies side by side -- no new lint surface. All nine files parse as YAML. The tag-check logic was exercised against the live GitHub API, including the prefix trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0b18e60 commit 860552b

10 files changed

Lines changed: 143 additions & 37 deletions

File tree

‎.github/actions/rust-setup/action.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ runs:
3434
# `@master`, which floats the ACTION's own implementation and can change CI behavior out from
3535
# under the pinned Rust toolchain input below without warning — caught in PR review). The
3636
# `toolchain:` input still wins over whatever default this ref's snapshot implies.
37-
- uses: dtolnay/rust-toolchain@1.96
37+
- uses: dtolnay/rust-toolchain@2fe4ca74464c5902a4f6e302d0a619b4ea911ccc # 1.96
3838
with:
3939
toolchain: ${{ inputs.toolchain }}
4040
components: ${{ inputs.components }}
4141
targets: ${{ inputs.targets }}
42-
- uses: Swatinem/rust-cache@v2
42+
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
4343
with:
4444
key: ${{ inputs.cache-key-suffix }}
4545
- name: Install Linux frontend system deps

‎.github/workflows/antigravity-review.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
# branch throughout. Consequence worth knowing: a PR that edits the reviewer or the
6464
# style guide is reviewed by the version already on the default branch until it merges.
6565
- name: Check out repo (for the style guide + scripts)
66-
uses: actions/checkout@v7
66+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
6767
with:
6868
ref: ${{ github.event.repository.default_branch }}
6969
# Not `fetch-depth: 0`: the large-diff fallback fetches exactly the two refs it

‎.github/workflows/ci.yml‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ jobs:
2929
outputs:
3030
code: ${{ steps.filter.outputs.code }}
3131
steps:
32-
- uses: actions/checkout@v7
33-
- uses: dorny/paths-filter@v3
32+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
33+
with:
34+
persist-credentials: false
35+
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
3436
id: filter
3537
with:
3638
filters: |
@@ -88,7 +90,9 @@ jobs:
8890
CARGO_HTTP_MULTIPLEXING: "false"
8991
runs-on: ubuntu-latest
9092
steps:
91-
- uses: actions/checkout@v7
93+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
94+
with:
95+
persist-credentials: false
9296
# fmt/clippy only ever check the HOST target here (no `--target` is ever passed to either
9397
# command in this job) — installing the wasm32/thumbv7em cross targets bought nothing but
9498
# setup time on every single PR push. Only `no_std` (which actually cross-compiles) needs
@@ -184,7 +188,9 @@ jobs:
184188
CARGO_HTTP_MULTIPLEXING: "false"
185189
runs-on: ubuntu-latest
186190
steps:
187-
- uses: actions/checkout@v7
191+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
192+
with:
193+
persist-credentials: false
188194
- uses: ./.github/actions/rust-setup
189195
with:
190196
cache-key-suffix: test-light
@@ -206,7 +212,9 @@ jobs:
206212
os: [ubuntu-latest, macos-latest, windows-latest]
207213
runs-on: ${{ matrix.os }}
208214
steps:
209-
- uses: actions/checkout@v7
215+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
216+
with:
217+
persist-credentials: false
210218
# Same rationale as `lint`: neither fmt nor clippy nor `cargo test` cross-compiles here, so
211219
# no extra targets are installed.
212220
- uses: ./.github/actions/rust-setup
@@ -265,7 +273,9 @@ jobs:
265273
- rustysnes-cart
266274
- rustysnes-core
267275
steps:
268-
- uses: actions/checkout@v7
276+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
277+
with:
278+
persist-credentials: false
269279
- uses: ./.github/actions/rust-setup
270280
with:
271281
targets: thumbv7em-none-eabihf
@@ -285,7 +295,9 @@ jobs:
285295
CARGO_HTTP_MULTIPLEXING: "false"
286296
runs-on: ubuntu-latest
287297
steps:
288-
- uses: actions/checkout@v7
298+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
299+
with:
300+
persist-credentials: false
289301
# The `rustysnes-core` `headless_frame` bench is chip-stack-only (no wgpu/winit/egui), so
290302
# no Linux frontend system deps are needed here (unlike `lint`/`full-test`).
291303
- uses: ./.github/actions/rust-setup
@@ -301,7 +313,9 @@ jobs:
301313
if: needs.setup.outputs.mode != 'skip'
302314
runs-on: ubuntu-latest
303315
steps:
304-
- uses: actions/checkout@v7
316+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
317+
with:
318+
persist-credentials: false
305319
- uses: ./.github/actions/rust-setup
306320
with:
307321
cache-key-suffix: accuracysnes

‎.github/workflows/ios.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ jobs:
4040
CARGO_NET_RETRY: "10"
4141
CARGO_HTTP_MULTIPLEXING: "false"
4242
steps:
43-
- uses: actions/checkout@v7
43+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
44+
with:
45+
persist-credentials: false
4446
- uses: ./.github/actions/rust-setup
4547
with:
4648
# `dtolnay/rust-toolchain` ignores its OWN `toolchain:`/`components:`/`targets:` inputs

‎.github/workflows/pgo.yml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ jobs:
7373
promotable: ${{ steps.gate.outputs.promotable }}
7474
speedup_pct: ${{ steps.gate.outputs.speedup_pct }}
7575
steps:
76-
- uses: actions/checkout@v7
76+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
77+
with:
78+
persist-credentials: false
7779

7880
# `components: llvm-tools-preview` is listed for documentation, but the EFFECTIVE source is
7981
# `rust-toolchain.toml` (which now includes it) -- `dtolnay/rust-toolchain` silently ignores
@@ -173,7 +175,7 @@ jobs:
173175
174176
- name: Upload promoted PGO binary
175177
if: steps.gate.outputs.promotable == 'true'
176-
uses: actions/upload-artifact@v7
178+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
177179
with:
178180
name: rustysnes-pgo-${{ steps.triple.outputs.triple }}
179181
path: target/${{ steps.triple.outputs.triple }}/release/rustysnes
@@ -194,7 +196,9 @@ jobs:
194196
permissions:
195197
contents: read
196198
steps:
197-
- uses: actions/checkout@v7
199+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
200+
with:
201+
persist-credentials: false
198202
- uses: ./.github/actions/rust-setup
199203
with:
200204
components: llvm-tools-preview
@@ -282,7 +286,7 @@ jobs:
282286
283287
- name: Upload BOLT binary
284288
if: steps.bolt_probe.outputs.have_bolt == 'true'
285-
uses: actions/upload-artifact@v7
289+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
286290
with:
287291
name: rustysnes-bolt-${{ steps.triple.outputs.triple }}
288292
path: target/${{ steps.triple.outputs.triple }}/release/rustysnes

‎.github/workflows/release-auto.yml‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,34 @@ jobs:
5353
should_release: ${{ steps.decide.outputs.should_release }}
5454
tag: ${{ steps.decide.outputs.tag }}
5555
steps:
56-
- uses: actions/checkout@v7
56+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
5757
with:
5858
# Build the release from the exact commit CI went green on. Full history so the
59-
# annotated-tag creation and the `git ls-remote` existence check both have what they
60-
# need (a shallow clone's tag/ref visibility isn't reliable here).
59+
# annotated-tag creation and the tag-existence check both have what they need (a shallow
60+
# clone's tag/ref visibility isn't reliable here).
6161
ref: ${{ github.event.workflow_run.head_sha }}
6262
fetch-depth: 0
63+
# DELIBERATELY NOT `persist-credentials: false`, and the only checkout in the repository
64+
# that keeps the token. Every other one was hardened in `v1.26.0` because checkout writes
65+
# the workflow `GITHUB_TOKEN` into `.git/config`, where anything the job then executes
66+
# from the tree can read it.
67+
#
68+
# This job is the exception because it genuinely needs it: it creates an annotated tag and
69+
# `git push origin "$TAG"` (below), which authenticates through exactly that credential.
70+
# The sibling project's audit concluded no job needed the token; that conclusion does NOT
71+
# transfer here, which is why this was audited per-site rather than applied blanket.
72+
#
73+
# The exposure is bounded by the trigger: `workflow_run` on a completed CI run of `main`,
74+
# never a pull request, so the tree this job executes is already-reviewed code.
6375

6476
- name: Decide whether a closed-out version needs releasing
6577
id: decide
6678
shell: bash
79+
env:
80+
# Required by the `gh api` tag-existence check below. `gh` errors out without it rather
81+
# than falling back to the ambient credential, and this step previously used only plain
82+
# `git`, so it had no token of its own. Same form as the release steps further down.
83+
GH_TOKEN: ${{ github.token }}
6784
run: |
6885
set -euo pipefail
6986
# ANY non-blank content under [Unreleased] (not just a "- " bullet -- a stray heading
@@ -93,7 +110,26 @@ jobs:
93110
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
94111
echo "version=${version}" >> "$GITHUB_OUTPUT"
95112
echo "header=${header}" >> "$GITHUB_OUTPUT"
96-
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
113+
# FAIL CLOSED. This was `git ls-remote --exit-code --tags origin "refs/tags/${tag}"`,
114+
# which collapses THREE outcomes into two: tag present (0), tag absent (2), and lookup
115+
# failed (non-zero, various). Reading any non-zero as "absent" means a transient network
116+
# blip, an auth hiccup, or a rate limit sends an ALREADY-RELEASED version down the
117+
# `should_release=true` path — re-tagging a published release, which
118+
# `to-dos/ROADMAP.md`'s release ceremony treats as immutable.
119+
#
120+
# `git/matching-refs` is chosen over `git/ref/tags/<tag>` deliberately: it answers
121+
# "absent" with HTTP 200 and an empty array, so a genuine miss can never be confused with
122+
# an error and no error-body parsing is needed. It matches by PREFIX, though, so the exact
123+
# ref is compared in `jq` — that is not theoretical, `v1.2` prefix-matches several real
124+
# tags while exact-matching none.
125+
#
126+
# Every failure path aborts the job (the script runs under `set -euo pipefail`) rather
127+
# than resolving to a release decision.
128+
if ! matching="$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}")"; then
129+
echo "::error::Tag lookup for ${tag} failed. Refusing to guess whether it exists."
130+
exit 1
131+
fi
132+
if printf '%s' "$matching" | jq -e --arg ref "refs/tags/${tag}" 'any(.[]; .ref == $ref)' >/dev/null; then
97133
echo "Tag ${tag} already exists -- nothing to release."
98134
echo "should_release=false" >> "$GITHUB_OUTPUT"
99135
else

‎.github/workflows/release.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ jobs:
4343
# `push` trigger, where `github.ref_name` is already the pushed tag).
4444
TAG: ${{ inputs.tag || github.ref_name }}
4545
steps:
46-
- uses: actions/checkout@v7
46+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
4747
with:
48+
persist-credentials: false
4849
ref: ${{ env.TAG }}
49-
- uses: dtolnay/rust-toolchain@stable
50+
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
5051
with:
5152
targets: ${{ matrix.target }}
52-
- uses: Swatinem/rust-cache@v2
53+
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
5354
with:
5455
key: release-${{ matrix.target }}
5556
- name: Install Linux frontend system deps

‎.github/workflows/security.yml‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,14 @@ jobs:
5151
name: Dependency Audit
5252
runs-on: ubuntu-latest
5353
steps:
54-
- uses: actions/checkout@v7
54+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
55+
with:
56+
persist-credentials: false
5557
# Install the PREBUILT binary, never `cargo install` (build-from-source): the repo pins
5658
# rustc 1.96, but a from-source cargo-audit build can need a newer toolchain than that to
5759
# COMPILE. The prebuilt binary runs fine under any toolchain -- it only parses Cargo.lock,
5860
# it never compiles this project.
59-
- uses: taiki-e/install-action@v2
61+
- uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2
6062
with:
6163
tool: cargo-audit
6264
- run: cargo audit
@@ -74,10 +76,12 @@ jobs:
7476
name: Cargo Deny Check
7577
runs-on: ubuntu-latest
7678
steps:
77-
- uses: actions/checkout@v7
79+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
80+
with:
81+
persist-credentials: false
7882
# Same rationale as the audit job: the prebuilt binary avoids needing a newer toolchain
7983
# than the repo's pinned 1.96 just to build the checker itself. Policy lives in `deny.toml`.
80-
- uses: taiki-e/install-action@v2
84+
- uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2
8185
with:
8286
tool: cargo-deny
8387
- run: cargo deny check
@@ -112,12 +116,14 @@ jobs:
112116
# indistinguishably from a real finding.
113117
timeout-minutes: 180
114118
steps:
115-
- uses: actions/checkout@v7
119+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
120+
with:
121+
persist-credentials: false
116122
# cargo-fuzz needs nightly (`-Z sanitizer=address`); the project pins 1.96 stable in
117123
# `rust-toolchain.toml`. Installing nightly alongside it, and invoking it explicitly as
118124
# `+nightly`, keeps the pin authoritative for every other job.
119-
- uses: dtolnay/rust-toolchain@nightly
120-
- uses: taiki-e/install-action@v2
125+
- uses: dtolnay/rust-toolchain@4fd1da8b0805d2d2e936788875a7d65dbd677dc2 # nightly
126+
- uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2
121127
with:
122128
tool: cargo-fuzz
123129
# `fuzz/` depends on `rustysnes-frontend` (the patch/preset/config/symbols boundaries live
@@ -162,7 +168,7 @@ jobs:
162168
# cargo +nightly fuzz run <target> <artifact>
163169
- name: Upload crashing inputs
164170
if: failure()
165-
uses: actions/upload-artifact@v4
171+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
166172
with:
167173
name: fuzz-artifacts
168174
path: |

‎.github/workflows/web.yml‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ jobs:
6565
name: build demo + docs
6666
runs-on: ubuntu-latest
6767
steps:
68-
- uses: actions/checkout@v7
68+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
69+
with:
70+
persist-credentials: false
6971
# GOTCHA: keep crates/rustysnes-frontend/web/Trunk.toml's wasm-bindgen pin == Cargo.lock
7072
# library version. `linux-frontend-deps: "true"` because the rustdoc build below compiles
7173
# the whole workspace (incl. rustysnes-frontend) for the host, which needs the native
@@ -81,7 +83,7 @@ jobs:
8183
# + its whole dependency tree from source on every single run — minutes of pure setup cost
8284
# for a tool whose own source never changes here).
8385
- name: Install trunk
84-
uses: taiki-e/install-action@v2
86+
uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2
8587
with:
8688
tool: trunk
8789

@@ -112,7 +114,7 @@ jobs:
112114
run: cargo doc --workspace --exclude rustysnes-android --no-deps
113115

114116
- name: Set up Python for MkDocs
115-
uses: actions/setup-python@v5
117+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
116118
with:
117119
python-version: "3.12"
118120

@@ -148,11 +150,11 @@ jobs:
148150
149151
- name: Configure Pages
150152
if: github.event_name != 'pull_request'
151-
uses: actions/configure-pages@v5
153+
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
152154

153155
- name: Upload Pages artifact (demo + docs)
154156
if: github.event_name != 'pull_request'
155-
uses: actions/upload-pages-artifact@v3
157+
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
156158
with:
157159
path: _site
158160

@@ -168,4 +170,4 @@ jobs:
168170
steps:
169171
- name: Deploy to GitHub Pages
170172
id: deployment
171-
uses: actions/deploy-pages@v4
173+
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

‎CHANGELOG.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
1010
## [Unreleased]
1111

12+
### Security
13+
14+
- **CI supply-chain hardening — every workflow credential, action pin, and the release gate.**
15+
Three items, the first two carried over from the sibling project's `v2.2.2` audit and the third
16+
raised in review of the fuzzing PR.
17+
18+
**`persist-credentials: false` on 15 of 17 checkouts.** `actions/checkout` writes the workflow
19+
`GITHUB_TOKEN` into `.git/config`, where anything the job then executes from the tree — build
20+
scripts, proc macros, test binaries, `scripts/*.sh`, the MkDocs build — can read it. On a pull
21+
request that tree is by definition unreviewed code, and nearly every job here compiles or runs
22+
it. Highest exposure was `web.yml`'s `build`, which holds `pages: write` + `id-token: write`
23+
while running `trunk`, `cargo doc`, `pip install`, and `mkdocs build`.
24+
25+
Audited per site rather than applied blanket, and that mattered: **`release-auto.yml`'s `prepare`
26+
job genuinely needs the credential** — it creates an annotated tag and `git push origin "$TAG"`,
27+
which authenticates through exactly that token. The sibling's audit concluded no job needed it;
28+
that conclusion does not transfer, and a blanket sweep would have broken every future release.
29+
It is now the only checkout in the repository that keeps the token, with the reason recorded
30+
inline, and its exposure is bounded by its trigger (`workflow_run` on a completed CI run of
31+
`main`, never a pull request).
32+
33+
**The release-tag existence check now fails closed.** It was
34+
`git ls-remote --exit-code --tags origin "refs/tags/${tag}"`, which collapses three outcomes into
35+
two — tag present, tag absent, and *lookup failed* — reading any non-zero exit as "absent". A
36+
transient network blip, auth hiccup, or rate limit therefore sent an already-released version
37+
down the `should_release=true` path, re-tagging a release the ceremony treats as immutable. Now a
38+
`gh api git/matching-refs/tags/<tag>` call, chosen over `git/ref/tags/<tag>` because it answers
39+
"absent" with HTTP 200 and an empty array, so a genuine miss can never be confused with an error.
40+
That endpoint matches by **prefix**, so the exact ref is compared in `jq` — verified necessary,
41+
not theoretical: `v1.2` prefix-matches **7** real tags while exact-matching none, and a naive
42+
`length > 0` would have reported it as already released. Every failure path aborts the job.
43+
44+
**All 34 third-party action references pinned to immutable commit SHAs**, across all 8 workflows
45+
plus the composite `rust-setup` action. An upstream tag move could previously change what CI
46+
executes without any repository review. Each pin carries its original tag as a trailing comment
47+
so the intent stays readable. Also aligned one `actions/upload-artifact@v4` (introduced with the
48+
fuzz job) to the `@v7` the rest of the repo already used.
49+
50+
Verified: `actionlint` reports the same three findings before and after (a self-hosted runner
51+
label and two pre-existing shellcheck notes) — no new lint surface; all nine files parse.
52+
1253
### Added
1354

1455
- **Fuzzing infrastructure (`fuzz/`) — fourteen `cargo-fuzz` targets, one per untrusted-input

0 commit comments

Comments
 (0)