Skip to content

ci(release): release-plz derives the bump and tags a merged release PR - #1007

Merged
IvanWng97 merged 10 commits into
mainfrom
ci/release-plz-flow
Sep 20, 2026
Merged

IvanWng97 merged 10 commits into
mainfrom
ci/release-plz-flow

Conversation

@IvanWng97

Copy link
Copy Markdown
Owner

Summary

The only judgement left in the release flow was the bump level: a human picked it, typed just bump X.Y.Z, and "features also bump minor" was upheld in review. release-plz derives it from the conventional-commit log plus cargo-semver-checks and opens the release PR, so the human steps become dispatch → just gen on the PR → merge.

release-plz owns versions and the tag only. release.yml is untouched and still owns every irreversible publish (crates.io + npm over OIDC, the GitHub release, provenance). just bump is deleted.

Needs a secret BEFORE this merges (not before the first dispatch): RELEASE_PLZ_TOKEN, a fine-grained PAT scoped to this repository with Contents and Pull requests read/write. The tag job runs on every push to main and the action refuses an empty token, so without it main reds on every push.

Related issue

n/a — owner call, 2026-09-20. Stacked on #1006 (the version popup no longer ships notes, so the release PR has nothing to hand-curate). Merge that one first.

Type of change

  • Refactor / chore (CI / release automation)

Config, and why each key

release-plz.toml carries the WHY on every key; the load-bearing ones:

Key Why
publish = false crates.io/npm/provenance stay in release.yml's OIDC jobs — no standing token
git_tag_name = "v{{ version }}" on pixtuoid alone release-plz's multi-package default is {{ package }}-v{{ version }}, which matches neither release.yml's v* trigger nor homebrew-core's autobump
changelog_update = false no CHANGELOG.md here; git-cliff renders the release body
release_always = false only a merged release-plz-* branch tags
features_always_increment_minor = true CONTRIBUTING#versioning, pre-1.0
semver_check = false the step holding the PAT must run no compiler — cargo-semver-checks builds both baselines, so every dependency build script would run with a write credential in its environment

No version_group: release-plz already takes the max next version for every version.workspace = true package before the group is consulted (updater.rs), verified by running it here with and without — identical output.

Where "what bump?" is answered now

ci-builds' semver job, narrowed to release PRs and guarded per step (a job-level if would make it skipped, which require-jobs reds). Between releases main carries unreleased changes at the shipped version, where a baseline check reds by design — the question only exists on the release PR. A red there means raise the bump on the branch with cargo set-version --workspace.

Two silent failures, two mechanisms

Both are classes that reach green while the release quietly does not happen:

  1. A tag created with GITHUB_TOKEN starts no workflow, so release.yml would never fire. Policy pins both release-plz steps to a repository secret (never the automatic token), resolved through the effective step<job<workflow env the zizmor token rule already walks — generalised over the variable name rather than copied.
  2. release-plz declines to tag silently when HEAD's PR did not come from a release-plz-* branch. The tag job asserts main's workspace version is tagged on origin: true at rest on every push, false exactly when a bump landed untagged.

Policy also pins what else is invisible: two live action steps carrying the exact {release-pr, release} command set — the action runs BOTH commands when command: is absent, so a step that loses the input opens a release PR on every push while the count still reads two — each [workspace] kill switch with the reason true for it, exactly one tagging package, and the two cross-file pairs a one-sided edit breaks (git_tag_name against release.yml's tag glob, pr_name against cliff.toml's skip parser). Seven deny rules with tests; release-plz.toml and cliff.toml join the conftest document set (yq fixes its input format per call, so the TOML pair rides a second call joined with jq).

Why release-pr is dispatch-only

release-plz refreshes an open release PR on every push to main, and closes and re-opens one that carries non-bot commits (release_pr.rs) — which would orphan the human's regenerated stills on the next dependabot merge, and re-run the full CI matrix each time.

How I tested it

  • Ran release-plz 0.3.169 against a throwaway worktree of main: 0.18.0 → 0.19.0, "(✓ API compatible changes)", diff = exactly the files cargo set-version touched.
  • release --dry-run: pixtuoid: creation of tag 'v0.19.0'; core/scene/hook "no release method enabled".
  • just lint green (includes ci-observability: 133 Rego unit tests, 113 policy checks).

Review (two-lens gate, round 1 folded)

Design-gate lenses (best-practice / adversarial / deepening) ran on the spec before any code; two review lenses ran on the diff. Folded: the command-census hole above, a step-level if: that could empty the existence rule, one shared kill-switch message that was false for four of five keys, pr_branch_prefix declared rather than inherited, a ${{ }} interpolated into a run: block (zizmor), the secret's real deadline, and five one-home-per-story trims.

SURFACED to the owner, not fixed here:

  • The tag job is the shallow half: with publish/release/changelog off, release-plz release reduces to a tag-exists check plus a create-ref, and the assertion step then re-checks the same invariant. A gh api create-ref would delete the silent-decline class and the per-push install of three cargo tools — at the cost that a hand-bumped merge would publish with no second human click.
  • Per-PR API-break classification is gone (goldens show the diff; a human classifies). A per-PR cargo semver-checks --release-type <t> keyed off the PR's conventional marker would restore it, as its own PR through the design gate.
  • Every upstream claim cited above was fetched this session from release-plz's docs and source, not recalled.

Checklist

  • Read the root CLAUDE.md.
  • New behavior has a test (six policy rules, one test each).
  • Docs updated in the same commit (docs/CONTRIBUTING.md #releasing/#versioning/#ci-gates, CLAUDE.md).
  • just preflight passes locally.

AI assistance

  • This PR was written/heavily-assisted by an AI agent.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 33c28cc2f7f644a9005524a5bd5f48a98b33a523

Findings: 0 — No findings

The diff touches a trust boundary (new release-plz.yml CI workflow handling a write-scoped RELEASE_PLZ_TOKEN PAT), but it follows secure patterns (SHA-pinned action, no pull_request/pull_request_target trigger, minimal permissions blocks, secret scoped to single steps, env-indirection instead of direct ${{ }} interpolation in shell, and semver_check/compilation explicitly disabled in the secret-bearing steps to avoid running dependency build scripts with the token) with no exploitable issue found.


Automated read-only review by Claude Code

@codspeed

codspeed Bot commented Sep 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 18 untouched benchmarks


Comparing ci/release-plz-flow (8952e21) with main (18b0f27)

Open in CodSpeed

@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.79%. Comparing base (18b0f27) to head (8952e21).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1007      +/-   ##
==========================================
- Coverage   95.79%   95.79%   -0.01%     
==========================================
  Files         199      199              
  Lines       59163    59163              
==========================================
- Hits        56678    56673       -5     
- Misses       2485     2490       +5     
Components Coverage Δ
pixtuoid-core 97.77% <ø> (-0.03%) ⬇️
pixtuoid-scene 95.41% <ø> (ø)
pixtuoid 94.23% <ø> (ø)
pixtuoid-hook 96.54% <ø> (ø)
pixtuoid-web 95.78% <ø> (ø)
Files with missing lines Coverage Δ
crates/pixtuoid/src/version.rs 100.00% <ø> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 52860a4f50fe857a11d86dd4b5cb2c326f553620

Findings: 0 — No findings

The diff adds a release-plz.yml CI workflow, guards it with a repository-scoped PAT (never GITHUB_TOKEN) via env-var indirection into shell steps, restricts triggers to push-to-main/workflow_dispatch (no pull_request/pull_request_target), and adds matching OPA policy guards; the rest is UI-only version-popup rework with no trust-boundary contact, and I found no verified vulnerability.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: c4e43d6121cccfad17ff9231b12f0071c506d57f

Findings: 0 — No findings

The diff adds a new PAT-driven release-plz CI workflow (touching the credential-handling trust boundary) plus unrelated UI/version-popup and doc changes; the new workflow follows safe patterns (SHA-pinned action, persist-credentials: false, minimal job permissions, repo/ref-gated triggers, secrets passed only via env to the pinned action step, no untrusted-PR code paths) and no exploitable vulnerability or invariant violation was found.


Automated read-only review by Claude Code

@IvanWng97

Copy link
Copy Markdown
Owner Author

Review dispositions (two-lens gate, round 1)

Both lenses returned APPROVE-WITH-NITS. Design lens: 6 MEDIUM. Correctness lens: 1 MEDIUM, 1 LOW.

FIXED

  • The action was pinned to the annotated tag object's sha, not its commit — Actions resolves uses: against commits, so the step could never have checked out. Caught only by zizmor's ref-version-mismatch, which needs the API; local zizmor is offline by default. Now b8d6b54b… (52860a4f).
  • conftest fmt formats with the OPA it embeds, and a dev build disagrees with the pinned v0.68.2 — CI's fmt --check red, local green (c4e43d61).
  • The existence rule counted action steps but never read with.command, and the action runs BOTH commands when the input is absent — a step that lost it would open a release PR on every push with the gate still green. Added a command-census rule.
  • A step-level if: could empty the existence rule; it is now a softener like continue-on-error.
  • One kill-switch message asserted of five keys what held for one. Each key now carries the reason true for it.
  • pr_branch_prefix declared in release-plz.toml, and now pair-pinned to ci-builds' semver guard: either side edited alone silently leaves every release PR ungated. Verified by mutation.
  • A ${{ }} interpolated into a run: block (zizmor template-injection) → routed through env.
  • The secret's deadline: before this merges, not before the first dispatch — the tag job runs on every push.
  • Five one-home-per-story trims across the toml, the workflow, the justfile and CONTRIBUTING.

REFUTED (with the mechanism)

  • "Add a concurrency group to the tag job" (LOW, a tag-race red). A bare group cancels a pending run by default (workflow-syntax#concurrency), which would trade a self-healing red for a silently skipped tag. The lossless form (queue: max) is forbidden outside release.yml by this repo's own policy rule, deliberately. The race reds visibly and the message says to re-run.
  • "A job whose steps all skip might report skipped and red require-jobs" — unverified by the lens, and it is the leg that would red every PR. Proven empirically on this PR: its head branch is not release-plz-*, all four steps skipped, semver-checks reports SUCCESS.

SURFACED (owner's call, not done here)

  • The tag job is the shallow half: with publish/release/changelog off, release-plz release is a tag-exists check plus a create-ref, and the assertion re-checks the same invariant. A gh api create-ref would delete the silent-decline class and the per-push install of three cargo tools — at the cost that a hand-bumped merge would publish with no second human click.
  • Per-PR API-break classification is gone (the goldens show a diff; a human classifies). A per-PR cargo semver-checks --release-type <t> keyed off the PR's conventional marker would restore it, as its own PR through the design gate.

@IvanWng97

Copy link
Copy Markdown
Owner Author

Addendum — the one leg the correctness lens left open, now doc-cited

The lens flagged that ci-builds.yml's guard reads github.head_ref inside a called workflow (ci.ymlci-builds.yml via workflow_call), and that no run in this repo distinguishes "head_ref is empty there" from "the branch simply doesn't match the prefix" — both skip. If head_ref were empty in a called workflow, semver would never run on any branch and the release PR would silently lose its only bump gate, always green. That is exactly the class this PR exists to remove, so it should not rest on reasoning.

GitHub's reusable-workflows reference settles it:

When a reusable workflow is triggered by a caller workflow, the github context is always associated with the caller workflow.

The caller is ci.yml, triggered by pull_request, so head_ref inside ci-builds.yml is the caller's PR head branch. The two neighbouring legs are doc-cited too: the context-availability table lists env as available to jobs.<job_id>.steps.if, and github as available to jobs.<job_id>.env.

Free confirmation on the first release PR: semver-checks should show its four steps running, not skipped. If they skip on a release-plz-* head, the guard is inverted and the gate is vacuous — say so and it becomes a one-line fix.

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 09d46dc07fbc7a53575f373157a72b9096c2a862

Findings: 0 — No findings

The diff's only trust-boundary-relevant surface is CI/CD credential handling in the new release-plz.yml workflow and the ci-builds.yml semver-gate change; both follow existing safeguards (SHA-pinned action, secrets passed via env not string-interpolated into shell, fork-safe repository/ref guards, minimal job permissions with the write-capable PAT scoped separately, and OPA policy enforcing the token-must-be-a-secret and dangerous-setting kill-switches), so no exploitable vulnerability or invariant violation was found; the remainder of the diff (TUI version-popup rewording, docs, justfile) has no security-relevant content.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 09d46dc07fbc7a53575f373157a72b9096c2a862

Findings: 1 (0 high, 1 medium)

Clean, well-tested PR that migrates releases to release-plz (CI workflows, justfile, Rego policy contracts) and simplifies the version popup to an "Updated to vX.Y.Z" notice linking to the GitHub release page, dropping the curated release_notes()/bump machinery it depended on; extensive cross-file consistency (cliff.toml skip pattern, release.yml tag glob, require-jobs per-step guarding) checks out under manual verification, and all removed call-sites/tests are fully accounted for — only a minor self-contradicting doc comment was found.

1. MEDIUM — justfile:11

The updated top-of-file group summary says release — the release-PR gates (npm-check, semver), but semver's recipe is tagged [group('rust')] (unchanged by this PR — confirmed further down in the same file), not [group('release')]. After this PR removes bump and notes-curated (both were [group('release')]), npm-check is the ONLY recipe left in the release just-group — running just --list grouped by release will not show semver, contradicting this same comment block's own claim that recipes are 'grouped by intent (see just --list)'. Either drop 'semver' from the summary or move it into [group('release')].


Automated read-only review by Claude Code

IvanWng97 and others added 5 commits September 20, 2026 13:34
The only judgement left in the release flow was the bump level, picked by
hand in `just bump` and upheld "in review". release-plz derives it from
the conventional-commit log plus cargo-semver-checks, so the human steps
become: dispatch, regenerate the committed art on the PR, merge.

release-plz owns versions and the tag ONLY; release.yml is untouched and
still owns every irreversible publish (crates.io + npm over OIDC, the
GitHub release, provenance). `publish = false` keeps it that way, and
`git_tag_name = "v{{ version }}"` is what release.yml's `v*` trigger and
homebrew-core's autobump need — release-plz's multi-package default
(`{{ package }}-v{{ version }}`) would match neither.

`semver_check = false`: the step holding the PAT must run no compiler,
or every dependency's build script runs with a write credential in its
environment. ci-builds' `semver` job answers the same question on the
release PR instead, guarded per STEP because require-jobs reds a skipped
job. That is also where the per-PR gate went: between releases main
carries unreleased changes at the shipped version, where a baseline
check reds by design.

`release-pr` is dispatch-only. release-plz refreshes an open release PR
on every push to main, closing and re-opening one that carries non-bot
commits — which would orphan the human's regenerated stills on the next
dependabot merge.

Two silent failures get mechanisms, both being classes that reach green:
- a tag created with GITHUB_TOKEN starts no workflow, so `release.yml`
  would never fire. Policy pins both release-plz steps to a repository
  secret (never the automatic token), by the effective step<job<workflow
  env the zizmor token rule already resolves, generalised over the name.
- release-plz tags only a merged `release-plz-*` branch and DECLINES
  silently otherwise. The `tag` job asserts main's workspace version is
  tagged — true at rest on every push, false exactly when a bump landed
  untagged.

The six policy rules pin what is otherwise invisible: two live action
steps carrying the exact `{release-pr, release}` command set (the action
runs BOTH when `command:` is absent, so a step that loses the input
opens a release PR on every push while the count still reads two), each
`[workspace]` kill switch with the reason true for IT, exactly one
tagging package, and the two cross-file pairs a one-sided edit breaks —
`git_tag_name` against release.yml's tag glob, `pr_name` (the squash
subject) against cliff.toml's skip parser. `release-plz.toml` and
`cliff.toml` join the conftest document set; yq fixes its input format
per call, so the TOML pair rides a second call joined with jq.

`pr_branch_prefix` is declared rather than inherited: it is release-plz's
own default, and three of our mechanisms key on that literal.

Needs the RELEASE_PLZ_TOKEN secret (fine-grained PAT, this repository,
Contents + Pull requests read/write) BEFORE this reaches main — the
`tag` job runs on every push and the action refuses an empty token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CBh53j1hrMRy1WXJttrgc
`gh api .../git/ref/tags/v0.5.139` returns the annotated TAG object's sha;
Actions resolves `uses:` against commits, so the pin named an object no
checkout could reach. zizmor's `ref-version-mismatch` says so — but only
ONLINE, which is why a green local run (offline by default) and a red CI
hygiene job disagreed. Reproduced both ways with GH_TOKEN set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`conftest fmt` reformats Rego with the OPA it embeds, and a brew dev
build embeds a newer one than the pinned v0.68.2 (OPA 1.15.2): the two
disagree on whether a one-entry object literal wraps, so a locally
formatted file reds CI's `fmt --check`. Reproduced by installing the pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pr_branch_prefix` decides which PRs release-plz tags AND which PRs run
the bump gate, but ci-builds.yml's `semver` guard carried the literal
independently: either side edited alone leaves every release PR ungated,
silently. The two sibling pairs were already pinned; this is the third.

Verified by mutation — changing the guard literal alone now fails
ci-observability, and the real files stay silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… draft release

Three of the four toggles this migration opened at `false` were closing a
feature the repository then re-implemented elsewhere. Upstream's own shape
(and Zebra's, and augurs') is to let release-plz own them:

- the changelog moves into `release-plz.toml`'s `[changelog]` section, so
  `cliff.toml` is DELETED and one format now renders both `CHANGELOG.md`
  and the GitHub release body. release.yml's git-cliff step goes with it.
  Verified against a real run: four crates produce ONE changelog file.
- `cargo-semver-checks` runs where the bump is chosen — release-plz writes
  the verdict into the release PR. ci-builds' `semver` job, its per-step
  release-PR guard, `just semver` and the `pr_branch_prefix` pair-pin that
  existed only to hold that guard's literal all retire with it.
- release-plz creates the GitHub release as a DRAFT carrying the changelog,
  and release.yml fills it. `softprops/action-gh-release` retains every
  release key it is not given and publishes a draft once assets are
  attached, so the version is never visible without its binaries and the
  job loses its `body:` and `prerelease:` inputs (the latter to
  `git_release_type = "auto"`).

`git_tag_name` moves to the WORKSPACE level: every package's changelog
entry builds a compare link from it, and the per-package default names
`pixtuoid-core-v0.18.0`, a tag that does not exist. Verified — the link
is now `compare/v0.18.0...v0.19.0`.

`publish` stays false, for a different reason than before. The ordering
argument was weak (crates.io ships source, the matrix ships binaries, and
a musl link failure does not make the published crate wrong). The real
constraint is that crates.io Trusted Publisher records are bound to the
workflow presenting the OIDC claim, and all four crates name release.yml
— flipping it needs a release-plz.yml publisher added to each crate
FIRST, or the release fails after the tag exists.

Policy follows: the kill-switch map drops the two keys that are now true,
gains the reason crates.io imposes on `publish`, and pins that exactly one
package drafts the release. The skip-parser pair-pin becomes in-file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IvanWng97

Copy link
Copy Markdown
Owner Author

Reworked: three of the four toggles now let release-plz own the thing

Follow-up to the review above. The design gate and both lenses judged the original shape (everything release-plz does disabled, re-implemented in release.yml). Looking at what the ecosystem actually does — upstream's releasing-binaries.md, ZcashFoundation/zebra, grafana/augurs, orhun/flawz, cargo-generate — three of those falses were closing a feature this repo then rebuilt elsewhere.

Now true

  • Changelog moves into release-plz.toml's [changelog]. cliff.toml is deleted and one format renders both CHANGELOG.md and the release body; release.yml's git-cliff step goes with it. changelog_config is deprecated upstream in favour of this section. Verified against a real release-plz update run: four crates produce one changelog file, 40 lines, grouped by the same parsers.
  • cargo-semver-checks runs where the bump is chosen — release-plz writes the verdict into the release PR body. ci-builds' semver job, its per-step release-PR guard, just semver, cargo-semver-checks in setup-tools, and the pr_branch_prefix pair-pin that existed only to hold that guard's literal all retire with it.
  • The GitHub release is created as a DRAFT carrying the changelog; release.yml attaches the binaries and publishes it. softprops/action-gh-release retains every release key it is not given and publishes a draft once assets land, so the job loses its body: and prerelease: inputs (the latter to git_release_type = "auto"). The version is never visible without its binaries — which is what the old ordering gate was really protecting.

A bug the real run exposed: git_tag_name was only on the package, so each changelog entry built its compare link as pixtuoid-core-v0.18.0… — a tag that does not exist, a 404 on every release. Moved to the workspace level (only pixtuoid still creates a tag); the link is now compare/v0.18.0...v0.19.0.

publish stays false, for a different reason than before. My original argument — don't publish until all six targets build — is weak: crates.io ships source, the matrix ships binaries, and a musl link failure does not make the published crate wrong. Only cargo-generate gates it; everyone else surveyed leaves it true. The real constraint is that crates.io Trusted Publisher records are bound to the workflow presenting the OIDC claim, and all four crates name release.yml. Flipping this needs a release-plz.yml publisher added to each crate on crates.io first, or the release fails after the tag already exists and with release.yml's publish job deleted. That reason now lives in the config comment and in the policy's own failure message.

Policy follows: the kill-switch map drops the two keys that are now true, carries the crates.io reason on publish, and gains a rule pinning that exactly one package drafts the release — verified by mutation (removing git_release_draft reds ci-observability).

Gates: just lint green (134 Rego unit tests, 114 policy checks), actionlint, zizmor offline and online.

@github-actions

Copy link
Copy Markdown

Claude Security Review — did not run

Head: 717cea4b823ef0a92a4368f6249619b971118336

The automated second lens is ABSENT, not clean. No findings were
produced, so none being shown means nothing. Run log.

In the run log, total_cost_usd of 0 in the result object
means the request never reached inference — a spent quota, not a
broken contract.

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 717cea4b823ef0a92a4368f6249619b971118336

Findings: 1 (0 high, 1 medium)

This PR replaces the manual just bump/git-cliff release process with release-plz automation, and is well-executed: the removed semver CI job, cliff.toml, and just bump recipe are consistently cleaned up across CLAUDE.md, CONTRIBUTING.md, justfile, and version.rs, and the new release-plz.yml/release-plz.toml wiring is backed by thorough new policy/ci-observability rego rules and tests. One stale-docs gap survives: two review-protocol prompt files still list "semver" as a per-PR CI-only gate that this diff removes.

1. MEDIUM — .github/prompts/pr-review.prompt.md:92

This PR deletes the semver job from .github/workflows/ci-builds.yml (it no longer runs on ordinary PRs; cargo-semver-checks now runs only on the release-plz release PR per the updated docs/CONTRIBUTING.md#ci-gates). But this reviewer-instructions file still tells lens-1 reviewers to 'NAME any CI-only gate this diff can red (semver, gen-check, wasm-check, windows-test, insta orphans)' — a future PR review following this exact prompt will look for a semver gate that no longer exists for regular PRs, and .github/prompts/impl-plan.prompt.md:78 has the identical stale reference ('Name the CI-ONLY gates the change can red (semver, api-surface, doc-check, gen-check, wasm-check, windows-test, snapshots)'). Neither file was updated in this diff even though docs/CONTRIBUTING.md's own CI-gates section was.


Automated read-only review by Claude Code

The `semver` job left ci-builds.yml in this PR, but both reviewer prompts
still named it among the gates a diff can red — a future reviewer would
have gone looking for a gate that no longer exists on ordinary PRs.
CONTRIBUTING's own CI-gates section was updated; these two were not.

Found by the automated reviewer on the reworked head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: e5962195839c551f3cd5b4756f3692c3b9579b1d

Findings: 0 — No findings

The diff only touches CI/release tooling (release-plz workflow, CI job config, rego policy, docs, justfile) and a test message — none of it touches the hook shim, socket/pipe transport, config writes, untrusted transcript/JSONL/pack ingestion, or app-level credential/subprocess handling, so there are no security-relevant changes.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: e5962195839c551f3cd5b4756f3692c3b9579b1d

Findings: 0 — No findings

Coherent migration of the release process from the manual just bump/tag flow to release-plz automation; docs, justfile, CI workflows, and the ci-observability Rego policy (with new tests) are updated consistently and I found no stale references to the removed recipe or cliff.toml, no architecture-invariant violations, and no verifiable logic bugs.


Automated read-only review by Claude Code

The last of the four toggles. My original reason for holding it — don't
publish until all six targets build — was weak: crates.io ships SOURCE
and the matrix ships BINARIES, so a musl link failure does not make the
published crate wrong, and `cargo publish` verifies the source by
building it. Of ten surveyed repos only cargo-generate gates it.

The real constraint was crates.io's, not ours: a Trusted Publisher record
is keyed on the workflow FILENAME (`NewGitHubConfig.workflow_filename` in
crates_io_api_types), and all four crates named release.yml. The owner
has added release-plz.yml records, so the claim now verifies.

release.yml keeps everything release-plz does not produce: the six-target
build matrix, the debs, the assets, the provenance attestation, and npm —
whose packages wrap those binaries. Its `publish` job (59 lines, its own
index-propagation retry) retires in favour of release-plz's
`wait_until_published`.

The `tag` job grows `id-token: write` for the OIDC exchange and an ALSA
step, because `cargo publish` builds each crate and the default `audio`
feature reaches alsa-sys. Its timeout goes 10 -> 30 minutes to match.

Policy drops `publish` from the kill switches; the reason it carried now
lives on the config key, where it names the filename coupling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 930ab48cda6bb5cf44bdddcb84d3b213a0659777

Findings: 0 — No findings

This diff only touches CI/release-pipeline files (workflows, justfile, Rego policy, release-plz.toml, docs) and the credential-handling changes it does make — SHA-pinned release-plz action, per-job least-privilege permissions, fork/ref-guarded triggers, and env-var (not inline ${{ }}) interpolation in shell steps — are correctly scoped, so no exploitable finding or invariant violation was found at the trust boundaries this rule set covers.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 930ab48cda6bb5cf44bdddcb84d3b213a0659777

Findings: 1 (1 high, 0 medium)

One high-confidence regression: the new release-plz-based flow makes the irreversible crates.io publish unconditional on merge to main, dropping the previous explicit gate that required the full 6-target build+deb matrix to succeed first.

1. HIGH — .github/workflows/release-plz.yml:68

The tag job runs release-plz release (which, per release-plz.toml publish = true, publishes every crate to crates.io) on every push to main, with no needs: on anything — it fires as soon as the release PR is merged, before release.yml's build matrix ever runs. This removes a safety invariant the old publish job in release.yml explicitly enforced: needs: [check, build, deb], commented "Gate on the FULL build matrix, not just check: a crates.io publish is irreversible, so it must not fire for a tag whose binaries/debs don't build" (see release.yml base, now-deleted publish job). Main's required CI (ci-builds.yml: windows-check, packaging-linux/macos) does not exercise the same 6 cross-compilation targets release.yml's build job does (e.g. aarch64-pc-windows-msvc, aarch64-unknown-linux-gnu cross, aarch64-apple-darwin) — release.yml's own comment on that matrix even flags aarch64-pc-windows-msvc as "validated only at TAG time... re-pin to a dated label if a runner-image bump regresses it", i.e. a known-fragile leg. Concrete failure: that cross-build breaks after a release PR merges → crates.io already has the new version (published by the tag job) → release.yml's build/deb jobs fail → the release job (needs [build, deb]) never runs → the draft GitHub release release-plz created is never filled or published, and users can cargo install a version with no attached binaries. docs/CONTRIBUTING.md's own rewritten "Cutting the release" section (step 4) confirms this new ordering is real, not a misreading: "the tag job publishes every crate to crates.io over OIDC, creates vX.Y.Z...; the tag then fires release.yml, which builds the six targets and the debs, attaches them, publishes the draft".


Automated read-only review by Claude Code

IvanWng97 and others added 2 commits September 20, 2026 16:01
…nd-tag path

Round-2 review of the rework. Three real findings:

The `semver_check = false` pin this PR deleted carried the WHY for a
threat that flipping it REALIZES: cargo-semver-checks compiles inside the
step holding RELEASE_PLZ_TOKEN, so every dependency build script runs
with a repository-scoped write credential. `publish = true` moves `cargo
publish`'s build there too — previously that build ran in release.yml's
OIDC-only job with no PAT. The decision stands; its record should not
have gone with the pin. release-plz.yml now states the accepted risk
where the credential is declared, and names the mechanism that bounds it
(a GitHub App installation token).

The hand-tag recovery path — which this workflow's own error message
offers — has no draft release to inherit from, so `action-gh-release`
creates one with `prerelease` unset and an `-rc.N` tag lands as the
latest stable release. The `prerelease:` input deleted with the draft
handoff comes back; on the normal path it restates what
`git_release_type = "auto"` already chose. The error message now says
that path also loses the changelog body, and that re-running is the fix
that keeps it.

`git_tag_name` was declared twice, and policy pinned only the package
copy — so the workspace copy, the one every changelog compare link reads,
could drift unpinned. One declaration now, and the rule resolves it the
way release-plz does (package override, else workspace).

Also five stale references the sweep found, including CONTRIBUTING
contradicting itself about whether `semver-checks` is a CI job, and the
two rego identifiers still named after the deleted cliff.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 design lens. The job that publishes four crates to crates.io and
then tags was called `tag`, which hid the irreversible half from anyone
reading the Actions list; it is `release` now.

The crates.io Trusted Publisher record is keyed on this workflow's
FILENAME, so renaming the file stops publishing until every record is
updated. That was stated in release-plz.toml and in CONTRIBUTING —
neither is where someone renaming a workflow looks. It moves to the
file's own header.

`semver` was filed under CONTRIBUTING's #ci-gates, a list headed "these
run only in CI"; it is not a job at all. It moves to #releasing, with
what it actually does: a detected break RAISES the bump rather than
failing, so the release PR already carries the corrected version.

`just semver` comes back, marked local-only and not a gate. Deleting it
left no way for a human to see that verdict before dispatching, which is
the one thing the old CI job gave them.

Refuted, with the citation: the lens read release-plz's semver check as
advisory, so a breaking change under a `fix` subject would ship as a
patch. Upstream's semver-check.md says the opposite — "release-plz
updates the version of the package with a major semver Bump", 0.4.21 ->
0.5.0 — so it corrects the bump instead of reporting on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IvanWng97

Copy link
Copy Markdown
Owner Author

Round 2 dispositions

Two lenses reviewed the delta 09d46dc0..HEAD (the rework that handed four capabilities back to release-plz). Correctness: REQUEST-CHANGES, 1 HIGH + 2 MEDIUM + 2 LOW. Design: 1 HIGH + 4 MEDIUM + nits.

FIXED

  • The deleted pin took its own reasoning with it. semver_check = false carried the WHY for a threat that flipping it realizes: cargo-semver-checks compiles inside the step holding the PAT, so every dependency build script runs with a repository-scoped write credential — and publish = true moved cargo publish's build there too, where round 1 ran it in an OIDC-only job with no PAT. The decision stands; deleting its record did not. release-plz.yml now states the accepted risk where the credential is declared, and names the mechanism that bounds it (a GitHub App installation token, ~1 h).
  • The hand-tag recovery path published release candidates as stable. This workflow's own error message offers git tag && git push; that path has no draft to inherit from, so the action creates the release with prerelease unset and an -rc.N tag lands as Latest release. The prerelease: input deleted with the draft handoff is back — on the normal path it restates what git_release_type = "auto" already chose. The message now says that path also loses the changelog body, and that re-running the job is the fix that keeps it.
  • git_tag_name was two copies of one literal and policy pinned the wrong one — the workspace copy, which every changelog compare link reads, was unpinned and could drift green. One declaration now; the rule resolves it the way release-plz does (package override, else workspace). Verified by mutation.
  • The job that publishes four crates was called tag, hiding the irreversible half in the Actions list → release / "publish and tag a merged release PR".
  • The Trusted Publisher filename coupling was documented in release-plz.toml and CONTRIBUTING, neither of which is where someone renaming a workflow looks → moved to the file's own header.
  • semver was filed under #ci-gates, a list headed "these run only in CI", when it is not a job → moved to #releasing. just semver is restored, marked local-only and not a gate: deleting it left no way for a human to reproduce the verdict before dispatching.
  • Five stale references, the worst a CONTRIBUTING.md that contradicted itself about whether semver-checks is a job, plus two rego identifiers still named after the deleted cliff.toml.

REFUTED, with the citation

The design lens read release-plz's semver check as advisory — "a breaking change landing under a fix subject now gets a patch bump with no red anywhere". Upstream says the opposite: "When release-plz detects API breaking changes, it updates the version of the package with a 'major semver Bump'", 0.4.21 → 0.5.0 (semver-check.md). It corrects the bump rather than reporting on it, which is stronger than the red job it replaced, not weaker.

VERIFIED by the lenses against pinned sources, not READMEs

  • The draft handoff: action-gh-release@v3 github.ts:710 keeps draft on update, :762 patches draft: false once assets land, :694 retains the existing body when none is passed. The comment on that step is accurate.
  • Publish ordering: release-plz iterates a vec its own source documents as release-ordered (project.rs:109) and wait_until_published (release.rs:956) gates each crate on the index before the next.

OPEN — needs an owner decision, and it is the last one

publish = true leaves the crates.io upload with no CI gate. The release job has no needs, and ci.yml fires on the same push concurrently, so release-plz can publish before ci-gate finishes. Round 1 gated it on needs: [check, build, deb]; npm, equally irreversible, still carries that gate — the two are now asymmetric. Add that branch protection here is not strict, so the tag can land on a tree no CI ran.

Three shapes, owner's call:

  1. Turn on Require branches to be up to date for main — makes "the tree that was tested is the tree that merged" structural. Cost: every PR must update before merge. (Both lenses' recommendation, and mine.)
  2. Wait for ci-gate inside the release job — closes staleness and concurrency, at the cost of new machinery and a slower release.
  3. publish = false — back to round 1's gate, giving up this simplification.

docs/CONTRIBUTING.md currently asks the human to update the branch in prose, and CLAUDE.md is explicit that prose has no failure mode. Whichever shape wins, that sentence should become the mechanism.

@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: f0dc60ad1d4a02661a8a36b7c356ca27bd4ac156

Findings: 0 — No findings

The diff replaces the manual crates.io release process with release-plz.yml (new workflow with RELEASE_PLZ_TOKEN, id-token OIDC, and OPA policy guards); permissions are least-privileged (top-level permissions:{}, job-scoped grants), the token is enforced by policy to be a real repo secret rather than the automatic GITHUB_TOKEN, the action is SHA-pinned, checkouts use persist-credentials:false, and the one real risk (dependency build scripts executing in a token-bearing step during cargo publish/semver-checks) is explicitly documented in-file as an accepted risk with a stated mitigation path — no exploitable vulnerability or unverified credential/permission gap was found.


Automated read-only review by Claude Code

Round-2's open HIGH: `publish = true` leaves the crates.io upload with no
CI gate — the `release` job has no `needs` and `ci.yml` runs on the same
push concurrently, so release-plz can publish before `ci-gate` finishes.
CONTRIBUTING asked the human to update the branch first, and CLAUDE.md is
explicit that prose has no failure mode.

`main` now requires branches to be up to date (owner's call, applied to
the branch protection). That makes the guarantee structural: a release PR
cannot merge unless its tree IS main's tree, and that tree is the one its
own CI already passed. The runbook says what the setting buys instead of
asking for the behaviour, and #releasing now states why the ungated
upload is safe.

Two references to the job's old name follow the rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Claude Security Review

Head: 8952e21870628a62cc3aa54fc070ef9152a4a737

Findings: 1 (0 high, 1 medium)

The diff touches a credential-handling trust boundary — a new release-plz.yml workflow runs cargo compilation (semver-checks / publish) inside the step that holds a repo-write-scoped RELEASE_PLZ_TOKEN, a self-acknowledged but real exposure of that token to arbitrary dependency build scripts, while the rest of the CI/workflow/policy changes are safe refactors with no new attack surface.

1. MEDIUM — .github/workflows/release-plz.yml:65

The release-pr job exports GITHUB_TOKEN=${{ secrets.RELEASE_PLZ_TOKEN }} (a fine-grained PAT with Contents + Pull-requests read/write on this repo) into the exact step that invokes release-plz's release-pr command, which runs cargo-semver-checks and therefore compiles the crate plus its full dependency baseline. Previously semver-checks ran in ci-builds.yml's now-deleted semver job with no secret in its environment at all, so this diff is what newly exposes a repo-write credential to arbitrary transitive-dependency build scripts (build.rs) during compilation — any compromised or malicious crate in the dependency tree can read the token from its environment and exfiltrate it over the runner's network access, then use it to push commits/tags or manipulate PRs on this repository. The same pattern repeats in the release job (around line 186) for cargo publish. The workflow's own comment block (lines 24-31) labels this an 'ACCEPTED RISK' and names the fix (a short-lived GitHub App installation token) as not yet implemented, confirming the gap is real and currently unmitigated beyond the PAT's repo-only scope.


Automated read-only review by Claude Code

@github-actions

Copy link
Copy Markdown

Claude Review

Head: 8952e21870628a62cc3aa54fc070ef9152a4a737

Findings: 2 (1 high, 1 medium)

Release automation migration to release-plz is well-designed (rego policy pins the config/token/tag-prefix contract tightly, with matching tests), but the new release job that performs the irreversible crates.io publish + tag creation lacks a concurrency guard that its sibling job and this repo's own pixtuoid-release precedent both treat as mandatory for irreversible-publish jobs, and one pre-existing doc comment about semver being CI-only was left stale by the PR's own re-scoping of that recipe.

1. HIGH — .github/workflows/release-plz.yml:79

The release job (runs release-plz/action with command: release, which does the irreversible crates.io publish and creates the git tag + draft GitHub release) has no concurrency: block, unlike the release-pr job right above it (which explicitly sets group: release-plz-pr) and unlike this repo's own release.yml, which wraps ALL irreversible publish steps in a top-level concurrency: {group: pixtuoid-release, queue: max, cancel-in-progress: false} specifically because (per that file's own comment) 'different tags must not interleave irreversible crates.io/npm publication, and a publish must never be interrupted.' Concretely: if a second non-bot push lands on main while a prior release-PR-merge push is still running its release job (a ~30 min window per timeout-minutes: 30), GitHub Actions will start a second release job concurrently. Both instances check out main, and if the second push also happens to be (or race with) a release-plz-branch merge, two release-plz release processes can run at once, both deciding 'not yet released' from local tags before either has pushed the new tag, and both then attempt cargo publish/tag creation — the exact interleaving hazard the repo's own release.yml concurrency group exists to prevent.

2. MEDIUM — justfile:511

The doc comment on the snapshots recipe ("CI-only in practice (a second full test run, like coverage/semver)") still describes semver as a CI-only gate, but this PR removes the semver CI job from ci-builds.yml entirely and rewrites the semver recipe's own doc comment a few lines above (justfile ~line 414-419 pre-diff) to say 'Reproduce release-plz's semver verdict LOCALLY. Not a gate and not in CI.' The snapshots recipe's parenthetical now cites semver as an example of something that is 'CI-only in practice,' which directly contradicts the PR's own redefinition of that same recipe just above it — CLAUDE.md requires docs stay current in the same commit as the change that makes them stale.


Automated read-only review by Claude Code

@IvanWng97

Copy link
Copy Markdown
Owner Author

SURFACED, merging anyway — the security reviewer's MEDIUM at release-plz.yml:65 is real and this change introduces it: both release-plz jobs now compile the dependency tree inside the step holding RELEASE_PLZ_TOKEN, so a malicious build.rs could read a repo-write credential. Before this migration the release flow carried no long-lived credential at all — the human's own git push of the tag was the trigger.

Owner's call to accept it for now. The bound is real but narrow: the PAT is fine-grained, scoped to this repository, and carries only Contents + Pull requests. The named mechanism is a GitHub App installation token (~1 h, actions/create-github-app-token), which release-plz documents and its own repository uses; switching is a step-level change plus two secrets, fully decoupled from the release flow.

Ecosystem check for the record: of ~1968 workflow files invoking release-plz/action, 618 name a PAT secret and 255 use an App token, so the PAT shape is the majority. The ~1210 using secrets.GITHUB_TOKEN mostly ship no binaries and need no second workflow to fire.

@IvanWng97
IvanWng97 merged commit 7146fd8 into main Sep 20, 2026
48 checks passed
IvanWng97 added a commit that referenced this pull request Sep 21, 2026
…first crate (#1012)

* fix(release): the changelog covers the whole workspace, not just the first crate

The v0.19.0 release PR's CHANGELOG.md held 12 entries — every one a commit
touching `pixtuoid-core`. The board revamp (#1009), the version popup's move to
GitHub release notes (#1006) and everything else in scene/bin were absent, and
that file is the draft release's body: the very notes the popup now sends users
to.

Cause: `changelog_path = "./CHANGELOG.md"` sat at WORKSPACE level, so all four
packages wrote the same `## [0.19.0]` heading to one file; the first writer won
and the PR body showed four identical sections. release-plz's documented lever
is `changelog_include` ("include commits that belong to other packages"), and
its own release-plz.toml (fetched) uses exactly this shape: the released package
owns the root changelog and names the rest. Workspace `changelog_update = false`
keeps the other crates from writing per-crate files.

Verified with `release-plz update` 0.3.169 on a throwaway worktree of main:
12 -> 19 entries, incl. #1009 tagged **breaking**, #1006, #1010, #1007; no
per-crate CHANGELOG.md appears. Commits touching no workspace member (site/, ci)
stay out by design.

Pinned, because the failure is silent: `changelog_update` joins the workspace
kill-switches, and the package that carries the draft release must write the
changelog AND name every other member of Cargo.toml's `[workspace].members` —
Cargo.toml becomes a policy input. Checked against the real files: dropping
`pixtuoid-web` from the list reds `just ci-observability` by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(release): review round 1 — the census counts PUBLISHED members and fails closed

Both lenses approved with nits; no HIGH. Each read release-plz 0.3.169's source
(the version the pinned action installs), and one ran `release-plz update` in a
scratch clone with probe commits.

- `pixtuoid-web` in `changelog_include` was inert: release-plz drops a
  `publish = false` crate from an update entirely (`takes_part_in_release`), and
  an included name it cannot find is skipped without a warning — a web-only probe
  commit stayed out of the notes either way. The gate nevertheless REQUIRED the
  entry and its message claimed the crate "vanishes" without it. The census now
  reads each member's own manifest (name + `publish`), which also retires the
  unpinned "directory name == package name" assumption; web is dropped from the
  list.
- The census failed OPEN: with Cargo.toml missing from the policy input the set
  difference was empty and the rule green. Two existence rules close it — the
  release package must itself be a published member, and every member's manifest
  must be in the input. Checked on the real files: dropping `pixtuoid-hook` reds
  by name; a recipe that stops feeding the member manifests reds both rules.
- The kill-switch's WHY described the old failure, not what a flip does now (every
  other crate grows its own CHANGELOG.md). The `git_tag_name` rationale went stale
  with this change (only one package has changelog entries now; every package
  still finds its last release by that tag). The recipe is cited to the doc that
  matches it ("extra/single-changelog", One package), not to upstream's own
  config, which keeps per-crate files.
- Runbook: when `main` moves under a release PR, RE-DISPATCH — never "Update
  branch". Only a dispatch recomputes CHANGELOG.md, and the merge commit counts
  as a human's, so the next dispatch closes the PR and opens a new number (what
  happened to #1008). Also: a user-facing change that touched no packaged file is
  not in the generated notes; add it by hand as the last commit.

Not re-run locally after the fold: `release-plz update` needs ~GBs for its
rustdoc builds and the disk is full. The only config delta since the reviewed
run is the removal of the entry proven inert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@IvanWng97
IvanWng97 deleted the ci/release-plz-flow branch September 21, 2026 06:37
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.

1 participant