ci(release): rehearsal preview tag, workflow-revision tooling for legacy tags, and a semver changelog base - #2687
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…ing window and an unpeeled tooling HEAD Review round 1 on #2687. * preview_tag on a PUBLISHING dispatch was ignored with a ::notice, on the reasoning that refusing "would cost a build for no safety gain". That is wrong: resolve-source is the first job, builds nothing, and both build jobs need it, so exit 1 there costs zero build minutes — and the refusal is the only mechanical signal that the dispatcher's intent (preview) and action (publish) disagree. It now prints a ::error naming both inputs and exits 1. * The tooling guard now proves the .workflow-tooling checkout peeled to a real commit (git rev-parse --verify 'HEAD^{commit}') and prints the resolved commit next to TOOLING_SHA. github.workflow_sha on an annotated-tag push can be the tag OBJECT id; actions/checkout is expected to peel it, and nothing proved it. * The stable-release listing window goes 100 -> 200 and is named once (stable_tag_limit), and a listing that comes back exactly full is refused: a full page is indistinguishable from a truncated one and the changelog base is chosen only from what was listed. Contract tests move with them: the notice assertion becomes a refusal assertion that also pins resolve-source as needs-free with every build job downstream of it; the guard test is renamed to what it proves (compose-notes needs build-backend, so the guard runs AFTER the Windows build — it names a missing composer before the composer is invoked) and its exit-1 assertion is anchored to the guard step's own run block instead of the whole job, which carries several unrelated exit 1 lines.
…sal rule The release-trust doc still described the changelog base as "the newest published stable release", which is the pre-#2250 behaviour, and its rehearsal paragraph did not mention preview_tag at all. Both now match the workflow: the base is the newest stable release that sorts strictly before the target tag by semver, and preview_tag renders the real stable page on a rehearsal while a publishing dispatch that supplies it is refused before any build runs.
Fix roundRound 1 of the review pipeline. Head moved 1. MEDIUM —
|
| command | result |
|---|---|
node --test scripts/ci/release-desktop-dispatch.test.mjs |
86 tests, 86 pass, 0 fail — red first: 86/83/3 after the test edits and before the workflow edits, failing exactly the three assertions for findings 1, 3 and 4 |
node --test scripts/ci/select-changelog-base.test.mjs |
17 tests, 17 pass |
node --test scripts/ci/smart-ci/*.test.mjs |
91 tests, 91 pass |
node scripts/check-github-ops-governance.mjs |
passed |
node scripts/check-docs-governance.mjs |
passed |
bash -n over every extracted run: block |
25 blocks checked, 0 failures (the YAML also safe_loads, which is what the extractor does first) |
git diff --check |
clean |
| local simulation of the three new fail-closed fragments | truncation, HEAD peel and the preview/publish refusal all behave as intended |
Not verified: Workflow Lint / actionlint / shellcheck cannot run locally — actionlint is not installed on this box, so the hosted Workflow Lint job at the new head is the evidence for it. Nothing was executed against the real GitHub release API: the gh release list truncation refusal and the rev-parse peel are proved by local simulation and by the contract tests, not by a dispatch. No dotnet or frontend surface is touched, so no backend or frontend suite was run.
Test count is unchanged at 86 — every change to the suite tightened or corrected an existing test rather than adding one.
Two rounds is the ceiling; this PR parks under SC-10 for the maintainer after this round.
Review disposition (beta-platform-integrity lane): parked under SC-10This PR changes Round 1 (fresh-context, read-only reviewer at 8a98c96): SHIP, no CRITICAL or HIGH; one MEDIUM, four LOW. Confirmed by reading every job: Fixed in the fix round (b84abdb, 6b591eb) and after (64cd68f, docs only)
Recorded, not fixed (maintainer's review)
Verification (local; the hosted run on the exact head is the R4 proving check)
|
…D-14's defaults; note D-10's superseded audit description
Readiness: the human-gate table was not unchanged at 15:15Z, its SC-10 row now counts twelve (#2684 and #2687 named); the cancelled 6cf9ef7 run had 15 green, Secret Scan skipped, one cancelled; #2691's seeding time and its 03:26Z red are exact; the dropped clause-4 evidence (cancelled-run range, run ids, timestamps) is restored; #2378 and #2588 occurrence lists gain the sweep's sightings. STATUS: same job tally; range note that the sweep's last merge is one past the block's declared bound. OUTSTANDING_TASKS.md: the rule-3 Changelog entry for the D-12 tick and the section K exception sentence.
…e (resolve-source outputs: keep render_tag/render_prerelease alongside version)
…other checkouts (CI-11 guard)
|
Coordinator (2026-09-06, SC-10 delegated). Updated to |
Summary
Issue #2250 items 1 to 3, all in the Release Desktop dispatch path. Items 4 and 5 are PR #2684 and are not touched here.
Item 1, rehearsal preview tag. A new
workflow_dispatchinputpreview_tag(string, optional, empty by default). On a no-publish dispatch it makesresolve-sourceemit two extra outputs,render_tagandrender_prerelease, andcompose-notesrenders the page for that tag instead of the generatedv0.0.0-dryrun+<sha7>. The input is untrusted dispatch text, so it reaches Bash only through the step env varRAW_PREVIEW_TAGand clearsscripts/ci/validate-release-tag.shbefore any use, exactly likeinputs.tag.render_prereleaseis derived fromrender_tagby the same case statement that derivesprereleasefrom the real tag.The resolved
tag, thepublishdecision, the stamped product version, the archive name, the provenance asset and everygh releasecall keep using the resolved tag.create-releaseis still gated onpublish == 'true', which a preview tag never sets, and a contract test asserts thatbuild-frontend,build-backendandcreate-releasecontain no reference to the render or preview values at all. When the dispatch does publish, the input is ignored with a::notice::rather than rejected with an error: this dispatch publishes a real tag, and failing a build over an input that changes nothing about what gets published costs the build for no safety gain.The rendered page keeps the archive name and checksum of the archive that was actually built. Under a preview, the download link therefore reads
releases/download/v0.3.0/taskdeck-v0.0.0-dryrun+abc1234-win-x64.zip. That is deliberate: the SHA-256 printed on the page must belong to the file the page names, and a rehearsal link is inert because no release exists. What the preview does prove is the part the issue asked for, the stable fail-closed policy, the realUPGRADING.mdsection and the real curated notes file.Item 2, legacy-tag re-dispatch. Option (a) was chosen.
compose-notesnow takes a secondactions/checkoutof the workflow revision (ref: ${{ github.workflow_sha }},path: .workflow-tooling,persist-credentials: false) and runs the composer and the new selector from that path. Reasoning: the checksum chain stays intact. The ZIP and its.sha256are still produced bybuild-backendfrom the tagged tree and verified against it bycreate-release; only the page renderer moves. Every path the renderer READS is still relative to the tagged checkout, so no content from a later revision can be published under an older tag. Option (b) would have documentedv0.2.0as unpublishable while leaving a working fix on the table.Residual, stated plainly: option (a) removes the
MODULE_NOT_FOUNDfailure, not every pre-0.3 failure. A stable tag whose tree has nodocs/releases/notes/<tag>.mdstill fails, because the composer refuses to publish a stable page without curated highlights. It now fails at compose time with a named missing input instead of an opaque module error. A pre-0.3 STABLE tag is therefore still not re-publishable end to end without a decision about where its highlights come from, and this PR does not make one.Item 3, changelog base.
gh release list --exclude-pre-releases --exclude-drafts --limit 1returned the newest stable release by RELEASE DATE. The call is now--limit 100, given the same bounded 3-attempt retry with 10/20 second backoff asgenerate-notes(it was previously a single unretried call underset -e), and the base is chosen by a new dependency-free script,scripts/ci/select-changelog-base.mjs, which picks the newest candidate that sorts STRICTLY BEFORE the target tag by semver precedence. Its grammar mirrorsscripts/ci/validate-release-tag.sh. Candidates outside that grammar are skipped with a warning, an unparseable target is refused. The first-release path is unchanged: no candidate means noprevious_tag_name, logged, not silent. The existingprevious_tag != RELEASE_TAGguard is kept as redundant defence in depth even though strict ordering already excludes it.sort -Vwas not used. Precedence here has to placev0.3.0-rc.1beforev0.3.0, and the ordering is worth unit-testing directly.Root cause
Item 1:
resolve-sourcehad exactly one tag, so the rehearsal namev0.0.0-dryrun+<sha7>was also the render tag. That name carries a prerelease segment, so the composer always took the RC fallback and the uploadedcomposed-page-bodywas structurally a different page from the one a stable tag would render.Item 2: #1795 pins every checkout to the tagged commit so the published bytes provably come from that tag. The composer was invoked as
node scripts/ci/compose-release-notes.mjs, a path resolved inside that tagged checkout, which conflated release CONTENT (correctly tag-pinned) with workflow TOOLING (which has no reason to be).Item 3:
gh release listorders by release date and--limit 1took the first row, so "the newest stable release" and "the stable release preceding this tag" were treated as the same thing. They diverge the moment a stable tag is published before one of its own release candidates is re-run.Verification
Red first, then green. Every command run from the worktree root.
Item 1 and 2 contract tests plus the rewritten item 3 test, against the unchanged workflow:
The 15 failures were:
every checkout refuses to persist Git credentials;a dedicated job composes the page body and runs on the rehearsal path too;the composer receives the render tag, prerelease decision and repo through env;the composer is fed the checksum, UPGRADING.md and the curated notes for the tag;the changelog base is the newest stable release before the target tag, with bounded retries;the rehearsal preview tag reaches Bash only through a step env var;the preview tag input is declared rehearsal-only and never required;the preview tag clears the same grammar gate as a real release tag;render_tag falls back to the resolved tag and is ignored on a publishing dispatch;resolve-source publishes the render tag and its prerelease decision as outputs;no publishing step reads the preview tag or the render tag;the composer renders the preview tag while the asset stays the built one;workflow tooling is checked out from the workflow revision into its own path;the tooling checkout is proved to carry the composer before the build is spent;release content is still read from the tagged checkout, not the tooling one.Sample assertion text from that run:
Selector suite, before the script existed:
After writing the script but before wiring the suite into
ci-required.yml, 17 tests, 16 pass, 1 fail, the failure beingci-required runs this suite beside the release workflow contract.Green, after the changes:
Structural checks on the workflow itself:
resolve-source, build-frontend, build-backend, compose-notes, create-release, dispatch inputstag, preview_tag.shell: bashrun:block extracted and syntax-checked:bash -n checked 25 run blocks, 0 failures.Behavioural rehearsal of the changed render step, run against the real composer and the real selector from a
.workflow-tooling-shaped path, with a synthetic tagged tree (anUPGRADING.mdcarrying a## v0.3.0section, adocs/releases/notes/v0.3.0.md, and a checksum file for the dry-run archive):v0.3.0-rc.1, candidate listv0.3.0, v0.2.0, v0.1.1in date order: printedv0.2.0. This is the item 3 regression; the old call would have returnedv0.3.0.RENDER_TAG=v0.3.0,RENDER_PRERELEASE=false: rendered 1004 bytes, theBreaking changesbody from the UPGRADING section is present, the quick start link is pinned tov0.3.0, and the file name and SHA-256 shown are the built dry-run archive's.v0.0.0-dryrun+abc1234, prerelease): warnsUPGRADING.md has no "## v0.0.0-dryrun+abc1234" sectionanddocs/releases/notes/v0.0.0-dryrun+abc1234.md is missing or empty, and the UPGRADING body is absent. That contrast is exactly what item 1 exists to remove.Not verified
actionlintnorshellcheckis installed on this machine;ci-extended.ymlinstalls a pinned actionlint 1.7.12 by checksum at run time.bash -nover the extracted run blocks is not a substitute for ShellCheck. The specific thing to watch on the hosted run isgithub.workflow_sha, which is a documented GitHub context property but which I could not confirm actionlint 1.7.12 accepts. CI-control changes are hosted-only proving under.claude/rules/ci-control.md, so the hosted lint on this PR head is the real gate.scripts/ci/actionlint-bootstrap.test.mjsfails locally, 7 tests, 3 pass, 4 fail, all withACTIONLINT_BIN must point to the installed tool. That is pre-existing and environmental; the file is untouched by this PR.workflow_dispatchcannot be rehearsed from a PR, so every workflow claim above rests on the source-text contract suite plus the simulation, not on a real run. A rehearsal dispatch withpreview_tagset, and a re-dispatch of a pre-0.3 tag, are both maintainer actions.--limit 100bound was not exercised against a repository with more than 100 stable releases.Risk notes
preview_tagis the one input a maintainer needs to understand. It renders and never publishes. It is refused unless it clears the release-tag grammar, a dispatch that supplies it and publishes is refused outright inresolve-sourcebefore any build starts (round 1 below), andcreate-releaseremains gated onpublish, which the input cannot set. The contract suite asserts that no publishing job references it or the render outputs. The practical consequence to know: on a rehearsal the page will show the preview tag in its headings and links while naming the dry-run archive that was actually built, so the download link on a preview is intentionally not a working URL.persist-credentials: false, and the existing contract test now counts six checkouts rather than five and asserts the flag on every one.github.workflow_shais the commit of the workflow file that is running, so the tooling cannot come from a ref the dispatcher did not select. If GitHub ever stopped populating it the checkout would fail rather than silently take a default ref, and the explicit guard step names the missing composer.select-changelog-base.mjsfails closed on an unparseable target and skips unparseable candidates. A skip is a warning rather than an error so that an unrelated tag in the repository cannot fail a publish; the cost is that a stable release named outside the grammar would be invisible as a base.stable-tags.txtis written into the workspace of the tagged checkout. It is never uploaded and never reachesrelease-assets/.--limit 1shape.Files
.github/workflows/release-desktop.yml— thepreview_taginput, the render outputs, the tooling checkout and its guard, the semver changelog base, and a header comment recording all three decisions.scripts/ci/release-desktop-dispatch.test.mjs— 86 tests, up from 76.scripts/ci/select-changelog-base.mjs— new, dependency-free.scripts/ci/select-changelog-base.test.mjs— new, 17 tests..github/workflows/ci-required.yml— one step running the new suite beside the dispatch contract.Review round 1 (fix round)
Head
6b591ebcarries the fixes for the round-1 review of8a98c96. Five findings, five fixed, none declined:preview_tagon a PUBLISHING dispatch was ignored with a::notice, justified by a comment claiming a refusal "would cost a build for no safety gain". Factually wrong:resolve-sourceis the first job, builds nothing, and both build jobs need it, soexit 1there costs zero build minutes — and the refusal is the only mechanical signal that the dispatcher's intent (preview) and action (publish) disagree. Now a::errornaming both inputs, thenexit 1; the comment, the input description and the header comment say so, and the contract test pins the refusal plus the fact thatresolve-sourceisneeds:-free with every build job downstream of it.compose-notesneedsbuild-backend), and matched/exit 1/against the whole job block, which carries several unrelatedexit 1lines. Renamed to what it proves — the guard names a missing composer explicitly before the composer is invoked — and the assertion is anchored to the guard step's ownrunblock via a newstepBlock()helper. The guard was not moved intoresolve-source:compose-notesneeds its own.workflow-toolingcheckout regardless (artifacts do not cross jobs), so an early guard duplicates the checkout and the guard in two jobs rather than moving them, and the condition it guards — the workflow file and the composer being in the same tree at the sameworkflow_sha— is close to impossible, so the expected saving is near zero against a permanent duplicate.github.workflow_shaon an annotated-tag push may be the tag OBJECT id and nothing provedactions/checkoutpeels it. The guard step now runsgit -C .workflow-tooling rev-parse --verify 'HEAD^{commit}', fails closed with a named message when it does not resolve, and prints the resolved commit next toTOOLING_SHA. Asserted in the contract test.gh release list --limit 100had no truncation signal. The window is nowstable_tag_limit=200, named once so the check cannot drift from the limit, and a listing that comes back exactly full is refused with a::errorsaying the window must be widened. The contract test pins both the limit and the equality check.docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.mdstill described the base as "the newest published stable release" and never mentionedpreview_tag. Both sentences now match the workflow.docs/STATUS.md,OUTSTANDING_TASKS.md,docs/IMPLEMENTATION_MASTERPLAN.mdanddocs/releases/V0_3_0_READINESS.mdwere deliberately left alone.Verified at
6b591eb:node --test scripts/ci/release-desktop-dispatch.test.mjs— 86 tests, 86 pass (red first: 3 failing before the workflow change);select-changelog-base.test.mjs17/17;scripts/ci/smart-ci/*.test.mjs91/91;check-github-ops-governance.mjsandcheck-docs-governance.mjsboth pass;bash -nover all 25 extractedrunblocks, 0 failures;git diff --checkclean. Workflow Lint cannot run locally (noactionlinton this box) — the hosted job is the evidence.Two rounds is the ceiling. This PR parks under SC-10 for the maintainer after this round.
Refs #2250 (items 1 to 3; items 4 and 5 are PR #2684). Refs #2248, #2234, #1795. Control-plane change: parks under SC-10 for the maintainer's review after the fresh-context review.