Skip to content

security(ci): fail-closed release tag check + pin the toolchain action#321

Merged
doublegate merged 2 commits into
mainfrom
security/tag-check-failclosed-and-action-pin
Jul 21, 2026
Merged

security(ci): fail-closed release tag check + pin the toolchain action#321
doublegate merged 2 commits into
mainfrom
security/tag-check-failclosed-and-action-pin

Conversation

@doublegate

@doublegate doublegate commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Finishes the two items #318 tracked that #319 deliberately deferred. Addresses #318 — no closing keyword on purpose; the issue gets closed by hand after this is verified, since a keyword is exactly what closed it prematurely last time.

1. Fail-closed release-tag check

release-auto.yml decided whether to publish a release with:

git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1

That collapses three outcomes — tag present, tag absent, lookup failed — into a two-way answer, reading any non-zero exit as "absent". A transient network or auth blip therefore pushed an already-released version down the should_release=true path. This one call decides the entire release, so guessing is the single thing it must not do.

Now gh api repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$tag.

Why matching-refs and not the more obvious git/ref/tags/$tag: it answers "absent" with HTTP 200 and an empty array rather than a 404, so a genuine miss can never look like an error and no error-body parsing is needed to tell them apart.

Why the exact-ref jq compare is load-bearing, not defensive: matching-refs matches by prefix. Verified against the live API — v2.2 prefix-matches two real tags (v2.2.0, v2.2.1) while exact-matching none, so a naive non-empty check would report a nonexistent tag as present:

query prefix matches exact matches verdict
v2.2.1 (exists) 1 1 already released
v2.2 (not a tag) 2 0 needs release
v99.99.99 0 0 needs release

Fail-closed verified under shell: bash + set -euo pipefail, which is what the step actually runs: a gh HTTP error aborts the assignment (exit 1), and a non-array body makes jq error under pipefail (exit 5). Neither can resolve to a release decision; an unexpected match count aborts explicitly.

Worth flagging for anyone re-checking this: verifying it in this repo's local shell (zsh) gave a false "passes" — zsh's set -e handling of var="$(cmd)" differs from bash's. It must be exercised with bash -c.

Knock-on: because the check no longer touches Git, release-auto.yml's checkout drops its persisted credentials too. It was the single documented exception in #319's sweep, held back only by this ls-remote — so all 19 checkouts are now uniform and the exception comment is gone rather than left stale.

2. Pin dtolnay/rust-toolchain

.github/actions/rust-setup used @master — a branch ref advancing on every upstream commit, so each run silently resolved to whatever HEAD happened to be, with no review window. Every other action here uses a @vN tag, which is not expected to move.

The exposure is disproportionate: this composite feeds 12 of the 19 checkouts, including release.yml (contents: write, builds the shipped binaries) and web.yml (pages: write + id-token: write) — and it is the action that installs the compiler, where a compromise is very hard to detect from build output.

Pinned to e97e2d8cc328f1b50210efc529dca0028893a2d9, verified via the API as the current target of upstream's refs/tags/v1. The trailing # v1 is what Dependabot's github-actions ecosystem reads, and that ecosystem is already enabled in .github/dependabot.yml — so this doesn't trade a supply-chain risk for a stale-action one.

Deliberately not done

SHA-pinning the remaining 12 actions. They are @vN tags rather than branch refs, so they carry materially less risk than @master did, and a blanket pin is a policy decision about pin maintenance rather than a drive-by fix.

Verification note

release-auto.yml only runs on workflow_run after CI succeeds on main, so this PR's own CI cannot exercise it — the tag-check logic was instead verified directly against the live API and the fail-closed paths against bash, as above. The rust-setup pin is exercised by essentially every job in this run.

Summary by CodeRabbit

  • Security
    • Updated release automation to use a fail-closed tag-ref check, so any lookup/parse/API failure aborts instead of treating the tag as missing.
    • Reduced GitHub credential persistence by consistently setting persist-credentials: false on CI checkouts.
    • Pinned the Rust toolchain setup to a specific revision for more predictable CI builds.
  • Documentation
    • Expanded contributor/CI guidance, including stricter linking keywords rules and clarifications on tag-checking and tooling behavior (e.g., pre-commit rewriting in vendored trees).

Finishes the two items #318 tracked that PR #319 deliberately deferred.
Reopened first: #318 had auto-closed one second after #319 merged, purely
from a `Closes #318.` keyword in that commit body, while both items were
still present on main. Deliberately no closing keyword here — the issue is
closed by hand once this is verified.

1. Fail-closed release-tag check
-------------------------------
`release-auto.yml` decided whether to publish a release with:

    git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1

That collapses three distinct outcomes — tag present, tag absent, lookup
failed — into a two-way answer, reading ANY non-zero exit as "absent". A
transient network or auth blip therefore pushed an already-released version
down the should_release=true path. This one call decides the entire release,
so guessing is the single thing it must not do.

Replaced with `gh api repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$tag`.
`matching-refs` was chosen over the more obvious `git/ref/tags/$tag`
specifically because it answers "absent" with HTTP 200 and an empty array
rather than a 404 — a genuine miss can never look like an error, and no
error-body parsing is needed to tell the two apart.

`matching-refs` matches by PREFIX, so the exact ref is compared in jq. That
is load-bearing rather than defensive, and was verified against the live
API: `v2.2` prefix-matches two real tags (v2.2.0, v2.2.1) while exact-
matching none, so a naive non-empty check would have reported a nonexistent
tag as present.

Fail-closed behaviour verified under `shell: bash` + `set -euo pipefail`,
which is what the step actually runs: a `gh` HTTP error aborts the
assignment (exit 1), and a non-array body makes jq error under pipefail
(exit 5). Neither can resolve to a release decision. An unexpected match
count aborts explicitly. (Verifying this in the local zsh gave a false
"passes" result — zsh's `set -e` handling of `var="$(cmd)"` differs from
bash's, so the check must be exercised with `bash -c`.)

Because the check no longer touches Git, `release-auto.yml`'s checkout drops
its persisted credentials as well. It was the single documented exception in
#319's sweep, held back only by this ls-remote, so all 19 checkouts in the
repo are now uniform and the exception comment is gone rather than stale.

2. Pin dtolnay/rust-toolchain
-----------------------------
`.github/actions/rust-setup` used `dtolnay/rust-toolchain@master` — a BRANCH
ref that advances on every upstream commit, so every run silently resolved
to whatever HEAD happened to be, with no review window and no signal that
anything changed. Every other action in this repo is referenced by a `@vN`
tag, which is not expected to move.

The exposure is disproportionate: this composite is consumed by 12 of the
repo's 19 checkouts, including release.yml (contents: write, builds the
shipped binaries) and web.yml (pages: write + id-token: write), and it is
the action that installs the compiler — a position where a compromise is
very hard to detect from build output.

Pinned to e97e2d8cc328f1b50210efc529dca0028893a2d9, verified via the API as
the current target of upstream's refs/tags/v1. The trailing `# v1` is the
convention Dependabot's github-actions ecosystem reads; that ecosystem is
already enabled in .github/dependabot.yml, so this does not trade a supply-
chain risk for a stale-action one.

Deliberately still NOT done: SHA-pinning the remaining 12 actions. Those are
`@vN` tags rather than branch refs, so they carry materially less risk than
`@master` did, and a blanket pin is a policy decision about pin maintenance
rather than a drive-by fix.

Addresses #318.
Copilot AI review requested due to automatic review settings July 21, 2026 02:55
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ad6e2b74-5d64-46f0-9ad2-6b42118e2698

📥 Commits

Reviewing files that changed from the base of the PR and between 3b54a82 and 7d9bd23.

📒 Files selected for processing (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

The PR pins the shared Rust toolchain action, replaces release tag detection with a fail-closed GitHub API and jq flow, and documents related CI, buildbot, dependency, and repository operating rules.

Changes

CI hardening and release automation

Layer / File(s) Summary
Pin the shared Rust toolchain action
.github/actions/rust-setup/action.yml, CHANGELOG.md
The Rust setup action now uses a pinned commit instead of @master, while preserving toolchain, target, and component inputs; the security changelog records the pin.
Fail-closed release tag detection
.github/workflows/release-auto.yml, CHANGELOG.md
Release preparation uses authenticated gh api and jq to count exact tag matches, releases only for zero matches, skips for one match, and fails for unexpected results.
Document CI and repository operating rules
AGENTS.md
Operational guidance now covers checkout credentials, release checks, PR wording, pre-commit scope, libretro buildbots, MinGW bindgen, and lz4_flex features.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReleaseWorkflow
  participant GitHubAPI
  participant JQ
  ReleaseWorkflow->>GitHubAPI: Fetch matching tag refs
  GitHubAPI-->>ReleaseWorkflow: Return JSON refs
  ReleaseWorkflow->>JQ: Count exact tag-ref matches
  JQ-->>ReleaseWorkflow: Return match count
  ReleaseWorkflow->>ReleaseWorkflow: Set should_release or fail
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 9
✅ Passed checks (9 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main CI hardening changes: fail-closed tag validation and pinning the Rust toolchain action.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Docs-As-Spec Sync ✅ Passed PR only changes GitHub workflows/AGENTS/CHANGELOG; no files under crates/rustynes-{cpu,ppu,apu,mappers} changed, so no subsystem docs sync was required.
Changelog Entry For User-Visible Changes ✅ Passed PR is CI/tooling-only (.github workflows/actions); no source/user-facing code changes, and CHANGELOG.md already has an Unreleased Security entry.
No Unwrap/Expect/Panic On Untrusted Input ✅ Passed Touched files are YAML/docs only; the diff adds no .unwrap(), .expect(), or panic!() on untrusted input, and the new release check exits cleanly on failure.
Safety Comment On New Unsafe Blocks ✅ Passed The PR diff is docs-only (CHANGELOG.md); no new unsafe {} blocks or unsafe fn appear in the changed file.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/tag-check-failclosed-and-action-pin

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes the remaining CI hardening items from #318 that were intentionally deferred from #319: it makes the release-tag existence check in release-auto.yml fail-closed (eliminating “lookup failed” being treated as “tag absent”), and it removes a supply-chain footgun by pinning the Rust toolchain installer action away from a moving branch ref.

Changes:

  • Replaces git ls-remote tag probing in the auto-release workflow with an explicit gh api .../git/matching-refs + exact-ref jq filter that aborts on errors instead of guessing.
  • Drops the last persisted-Git-credentials checkout exception in release-auto.yml now that the tag check is API-based.
  • Pins dtolnay/rust-toolchain in the shared rust-setup composite action to a specific commit SHA (tracked via # v1).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
CHANGELOG.md Documents the completed hardening items (fail-closed tag check + toolchain action pin).
AGENTS.md Adds durable operational guidance capturing the “fail-closed tag check” and “no magic close keywords” lessons.
.github/workflows/release-auto.yml Implements fail-closed tag existence checking via gh api and removes the last persisted-credentials exception.
.github/actions/rust-setup/action.yml Pins dtolnay/rust-toolchain from a moving branch ref to a specific commit SHA.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 70-71: Update the continuation of the `persist-credentials: false`
changelog entry to state that all 19 checkouts are hardened, and remove the
outdated explanation describing `release-auto.yml` as a deliberate exception.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cc7eb60e-2eef-4e7b-ad16-0a06aeb8cf9a

📥 Commits

Reviewing files that changed from the base of the PR and between 85ee20d and 3b54a82.

📒 Files selected for processing (4)
  • .github/actions/rust-setup/action.yml
  • .github/workflows/release-auto.yml
  • AGENTS.md
  • CHANGELOG.md

Comment thread CHANGELOG.md Outdated
CodeRabbit flagged that the entry added here says release-auto.yml joined
the sweep while the #319 entry below it still said "18 of 19" and called
release-auto.yml a deliberate exception.

Correct, and the underlying problem is larger than the numbers: both
entries sit in the same [Unreleased] section, so they ship in one release
and a reader gets "we hardened 18 with one exception" followed by "...and
now the 19th". That is PR history leaking into release notes, which this
project explicitly does not want there ("Keep deep engineering/lineage
narrative out of the CHANGELOG"). Patching the count would have left two
entries narrating one coherent change.

Merged into a single entry describing the end state — all 19 checkouts
hardened, with the fail-closed tag check and the toolchain pin as the two
related items — and dropped the now-purely-historical intermediate
narrative. The audit facts worth keeping are retained verbatim:
rust-setup performs no checkout of its own, no job needed the credential,
and web.yml's build is the highest-exposure site.
@doublegate
doublegate merged commit 3429be8 into main Jul 21, 2026
22 checks passed
@doublegate
doublegate deleted the security/tag-check-failclosed-and-action-pin branch July 21, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants