From 48b2a1b1da0b9ea953d29ad42c9b4db8cf44a6cd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:54:10 +0100 Subject: [PATCH 1/6] ci(release): select the changelog base by semver, not release date --- .github/workflows/ci-required.yml | 6 + scripts/ci/select-changelog-base.mjs | 216 ++++++++++++++++++++++ scripts/ci/select-changelog-base.test.mjs | 184 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 scripts/ci/select-changelog-base.mjs create mode 100644 scripts/ci/select-changelog-base.test.mjs diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 94e6f5b1c..de704a925 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -127,6 +127,12 @@ jobs: - name: Validate release-desktop dispatch hardening contract run: node --test scripts/ci/release-desktop-dispatch.test.mjs + # The changelog base the release page is generated against (#2250). The + # selection is semver ordering, which a workflow-text assertion cannot + # execute, so the ordering itself is unit-tested here. + - name: Validate changelog base selection + run: node --test scripts/ci/select-changelog-base.test.mjs + - name: Validate release cache trust contract run: node --test scripts/ci/release-cache-contract.test.mjs diff --git a/scripts/ci/select-changelog-base.mjs b/scripts/ci/select-changelog-base.mjs new file mode 100644 index 000000000..4bd7f19f6 --- /dev/null +++ b/scripts/ci/select-changelog-base.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// ============================================================================= +// select-changelog-base.mjs — pick the changelog base for a release (#2250) +// ============================================================================= +// +// `releases/generate-notes` renders the "What's changed" section between a base +// tag and the tag being published. Release Desktop used to name that base with +// +// gh release list --exclude-pre-releases --exclude-drafts --limit 1 +// +// which returns the newest stable release by RELEASE DATE. Re-running +// `v0.3.0-rc.1` after `v0.3.0` had already shipped therefore sent +// `previous_tag_name=v0.3.0`, and the RC page rendered a changelog running +// backwards from a release that came after it. +// +// The base is instead the newest stable release that sorts STRICTLY BEFORE the +// tag being built, by semver precedence. Precedence is not string order either: +// `v0.10.0` is newer than `v0.4.0`, which a lexicographic sort reverses. +// +// The accepted tag shapes are exactly the ones `scripts/ci/validate-release-tag.sh` +// admits, so this script and the workflow's grammar gate cannot drift apart: +// +// v..[-][+] +// +// Anything else in the candidate list is skipped with a warning — an unrelated +// tag in the repository must not fail a publish — but an unparseable TARGET is +// refused, because the caller has already grammar-checked it and a mismatch +// there means something upstream is wrong. +// +// Dependency-free and importable: `select-changelog-base.test.mjs` exercises the +// ordering and the selection directly, plus the CLI shape the workflow calls. +// +// Usage: node scripts/ci/select-changelog-base.mjs --tag --candidates +// Output: the selected base tag on stdout, or nothing at all when the target is +// the first release. Nothing on stdout is the caller's "no base" signal. +// Exit: 0 selected (or legitimately empty) · 1 refused target · 2 wrong usage. +// ============================================================================= + +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +/** Mirrors TAG_GRAMMAR in scripts/ci/validate-release-tag.sh. */ +export const RELEASE_TAG_GRAMMAR = + /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?(?:\+([0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/ + +/** The same 64-character ceiling the shell gate enforces. */ +export const MAX_TAG_LENGTH = 64 + +/** + * Parse a release tag into its precedence-bearing parts. + * + * Build metadata is deliberately dropped: semver excludes it from precedence, + * and two releases differing only in build metadata are the same release for + * changelog purposes. + * + * @param {unknown} tag + * @returns {{major: number, minor: number, patch: number, prerelease: Array}|null} + */ +export function parseReleaseTag(tag) { + if (typeof tag !== 'string' || tag.length === 0 || tag.length > MAX_TAG_LENGTH) return null + const match = RELEASE_TAG_GRAMMAR.exec(tag) + if (!match) return null + const [, major, minor, patch, prerelease] = match + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + // A wholly numeric identifier compares numerically and ranks below any + // alphanumeric one, so its type is decided here rather than at compare time. + prerelease: + prerelease === undefined + ? [] + : prerelease.split('.').map((part) => (/^[0-9]+$/.test(part) ? Number(part) : part)), + } +} + +/** + * Semver precedence over the prerelease identifier lists. + * + * An EMPTY list means "no prerelease", which outranks any prerelease of the same + * version — v0.3.0 is newer than v0.3.0-rc.1. + */ +function comparePrerelease(left, right) { + if (left.length === 0 && right.length === 0) return 0 + if (left.length === 0) return 1 + if (right.length === 0) return -1 + const shared = Math.min(left.length, right.length) + for (let index = 0; index < shared; index += 1) { + const a = left[index] + const b = right[index] + const aNumeric = typeof a === 'number' + const bNumeric = typeof b === 'number' + if (aNumeric && bNumeric) { + if (a !== b) return a < b ? -1 : 1 + continue + } + if (aNumeric !== bNumeric) return aNumeric ? -1 : 1 + if (a !== b) return a < b ? -1 : 1 + } + // All shared identifiers are equal: the longer list wins (rc.1 < rc.1.1). + if (left.length === right.length) return 0 + return left.length < right.length ? -1 : 1 +} + +/** + * Compare two release tags by semver precedence. + * + * @returns {number} negative if `left` is older, 0 if equal, positive if newer. + * @throws {Error} when either tag is outside the release-tag grammar. + */ +export function compareReleaseTags(left, right) { + const a = parseReleaseTag(left) + const b = parseReleaseTag(right) + if (a === null) throw new Error(`${JSON.stringify(left)} does not match the release-tag grammar`) + if (b === null) throw new Error(`${JSON.stringify(right)} does not match the release-tag grammar`) + if (a.major !== b.major) return a.major < b.major ? -1 : 1 + if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1 + if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1 + return comparePrerelease(a.prerelease, b.prerelease) +} + +/** + * Choose the newest candidate that sorts STRICTLY BEFORE `targetTag`. + * + * Strictness is what keeps a re-run of an already-published tag from becoming + * its own changelog base, and what keeps a later release out of an earlier + * one's page. + * + * @param {string} targetTag the tag being published + * @param {string[]} candidateTags stable release tags, in any order + * @param {(message: string) => void} [warn] receives one line per skipped candidate + * @returns {string|null} the base tag exactly as it was listed, or null + */ +export function selectChangelogBase(targetTag, candidateTags, warn = () => {}) { + const target = parseReleaseTag(targetTag) + if (target === null) { + throw new Error(`target tag ${JSON.stringify(targetTag)} does not match the release-tag grammar`) + } + let best = null + for (const candidate of candidateTags) { + const trimmed = typeof candidate === 'string' ? candidate.trim() : '' + if (trimmed === '') continue + if (parseReleaseTag(trimmed) === null) { + warn(`ignoring ${JSON.stringify(trimmed)}: not a release tag`) + continue + } + if (compareReleaseTags(trimmed, targetTag) >= 0) continue + if (best === null || compareReleaseTags(trimmed, best) > 0) { + best = trimmed + } + } + return best +} + +export function parseArgs(argv) { + const options = {} + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + const match = /^--([a-z0-9-]+)$/.exec(token) + if (!match) throw new Error(`unexpected argument: ${token}`) + const value = argv[index + 1] + if (value === undefined || /^--[a-z0-9-]+$/.test(value)) { + throw new Error(`--${match[1]} requires a value`) + } + options[match[1]] = value + index += 1 + } + return options +} + +export function main(argv) { + let options + try { + options = parseArgs(argv) + } catch (error) { + process.stderr.write(`::error::select-changelog-base: ${error.message}\n`) + return 2 + } + if (!options.tag || !options.candidates) { + process.stderr.write( + '::error::select-changelog-base: usage: --tag --candidates \n', + ) + return 2 + } + + let listed + try { + listed = readFileSync(options.candidates, 'utf8') + } catch (error) { + process.stderr.write(`::error::select-changelog-base: ${error.message}\n`) + return 1 + } + + let base + try { + base = selectChangelogBase(options.tag, listed.replace(/\r\n/g, '\n').split('\n'), (message) => { + process.stderr.write(`::warning::select-changelog-base: ${message}\n`) + }) + } catch (error) { + process.stderr.write(`::error::select-changelog-base: ${error.message}\n`) + return 1 + } + + if (base === null) { + process.stderr.write(`No release sorts before ${options.tag}; the caller decides the fallback.\n`) + return 0 + } + process.stdout.write(`${base}\n`) + return 0 +} + +// Importable by the test suite; executed only when invoked as the CLI entry point. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = main(process.argv.slice(2)) +} diff --git a/scripts/ci/select-changelog-base.test.mjs b/scripts/ci/select-changelog-base.test.mjs new file mode 100644 index 000000000..fccec90c8 --- /dev/null +++ b/scripts/ci/select-changelog-base.test.mjs @@ -0,0 +1,184 @@ +// ============================================================================= +// select-changelog-base.test.mjs — changelog base selection (#2250 item 3) +// ============================================================================= +// +// The defect this suite pins: `gh release list --limit 1` returned the globally +// newest stable release by RELEASE DATE. Re-running `v0.3.0-rc.1` after +// `v0.3.0` had shipped therefore sent `previous_tag_name=v0.3.0`, and the RC +// page rendered a changelog that ran backwards. The base must be the newest +// stable release that sorts STRICTLY BEFORE the tag being built, by semver +// precedence — which is also not string order (`v0.10.0` > `v0.4.0`). +// +// Run: node --test scripts/ci/select-changelog-base.test.mjs +// ============================================================================= + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { + compareReleaseTags, + parseReleaseTag, + selectChangelogBase, +} from './select-changelog-base.mjs' + +const scriptPath = fileURLToPath(new URL('./select-changelog-base.mjs', import.meta.url)) +const ciRequiredPath = fileURLToPath(new URL('../../.github/workflows/ci-required.yml', import.meta.url)) + +function runCli(args, candidates) { + const dir = mkdtempSync(join(tmpdir(), 'changelog-base-')) + const file = join(dir, 'candidates.txt') + writeFileSync(file, candidates.join('\n'), 'utf8') + return spawnSync(process.execPath, [scriptPath, ...args, '--candidates', file], { + encoding: 'utf8', + }) +} + +// ----------------------------------------------------------------------------- +// 1. Tag parsing — the same grammar the workflow's gate enforces +// ----------------------------------------------------------------------------- + +test('parses the release tags Taskdeck ships', () => { + assert.deepEqual(parseReleaseTag('v0.3.0'), { + major: 0, + minor: 3, + patch: 0, + prerelease: [], + }) + assert.deepEqual(parseReleaseTag('v1.2.3-rc.1'), { + major: 1, + minor: 2, + patch: 3, + prerelease: ['rc', 1], + }) + assert.deepEqual(parseReleaseTag('v0.0.0-dryrun+abc1234'), { + major: 0, + minor: 0, + patch: 0, + prerelease: ['dryrun'], + }) +}) + +test('refuses anything outside the release-tag grammar', () => { + for (const tag of ['', '0.3.0', 'v0.3', 'main', 'refs/tags/v0.3.0', 'v01.2.3', 'v0.3.0-ré', null]) { + assert.equal(parseReleaseTag(tag), null, `expected ${JSON.stringify(tag)} to be refused`) + } +}) + +// ----------------------------------------------------------------------------- +// 2. Ordering — semver precedence, not string order and not release date +// ----------------------------------------------------------------------------- + +test('orders release numbers numerically, not lexicographically', () => { + // The bug a plain string sort (or `sort` without -V) would introduce. + assert.ok(compareReleaseTags('v0.10.0', 'v0.4.0') > 0, 'v0.10.0 is NEWER than v0.4.0') + assert.ok(compareReleaseTags('v0.9.0', 'v0.10.0') < 0) + assert.ok(compareReleaseTags('v1.0.0', 'v0.99.99') > 0) + assert.equal(compareReleaseTags('v0.3.0', 'v0.3.0'), 0) +}) + +test('a prerelease sorts before its own stable release', () => { + assert.ok(compareReleaseTags('v0.3.0-rc.1', 'v0.3.0') < 0, 'v0.3.0-rc.1 precedes v0.3.0') + assert.ok(compareReleaseTags('v0.3.0', 'v0.3.0-rc.1') > 0) + assert.ok(compareReleaseTags('v0.3.0-rc.1', 'v0.3.0-rc.2') < 0) + assert.ok(compareReleaseTags('v0.3.0-rc.9', 'v0.3.0-rc.10') < 0, 'rc identifiers compare numerically') + assert.ok(compareReleaseTags('v0.3.0-alpha', 'v0.3.0-alpha.1') < 0, 'more identifiers outrank fewer') + assert.ok(compareReleaseTags('v0.3.0-1', 'v0.3.0-alpha') < 0, 'numeric identifiers rank below alphanumeric') +}) + +test('build metadata is ignored for precedence', () => { + assert.equal(compareReleaseTags('v0.2.0+build.5', 'v0.2.0'), 0) + assert.equal(compareReleaseTags('v0.2.0', 'v0.2.0+build.5'), 0) +}) + +// ----------------------------------------------------------------------------- +// 3. Selection +// ----------------------------------------------------------------------------- + +test('re-running an RC after its stable release shipped does NOT pick that stable release', () => { + // The exact #2250 item 3 regression: gh returned v0.3.0 (newest by date). + assert.equal(selectChangelogBase('v0.3.0-rc.1', ['v0.3.0', 'v0.2.0', 'v0.1.1']), 'v0.2.0') +}) + +test('a stable release is never its own changelog base', () => { + assert.equal(selectChangelogBase('v0.3.0', ['v0.3.0', 'v0.2.0']), 'v0.2.0') +}) + +test('the newest candidate strictly before the target wins, by semver', () => { + assert.equal(selectChangelogBase('v0.4.0', ['v0.10.0', 'v0.3.0', 'v0.2.0']), 'v0.3.0') + assert.equal(selectChangelogBase('v1.0.0', ['v0.10.0', 'v0.9.0']), 'v0.10.0') + assert.equal(selectChangelogBase('v0.3.1', ['v0.3.0', 'v0.3.0-rc.1']), 'v0.3.0') +}) + +test('the first-release path returns nothing rather than guessing', () => { + assert.equal(selectChangelogBase('v0.1.0', []), null) + assert.equal(selectChangelogBase('v0.1.0', ['v0.2.0', 'v1.0.0']), null, 'only later releases exist') +}) + +test('candidates outside the grammar are skipped, not fatal', () => { + assert.equal(selectChangelogBase('v0.3.0', ['not-a-tag', 'nightly', 'v0.2.0']), 'v0.2.0') +}) + +test('the selected tag is returned exactly as it was listed', () => { + assert.equal( + selectChangelogBase('v0.3.0', ['v0.2.0+build.5']), + 'v0.2.0+build.5', + 'the tag handed to generate-notes must be the one GitHub actually knows', + ) +}) + +test('an unparseable target tag is refused instead of silently selecting nothing', () => { + assert.throws(() => selectChangelogBase('main', ['v0.2.0']), /release-tag grammar/) +}) + +// ----------------------------------------------------------------------------- +// 4. CLI — the shape the workflow calls +// ----------------------------------------------------------------------------- + +test('the CLI prints the selected base and exits 0', () => { + const result = runCli(['--tag', 'v0.3.0-rc.1'], ['v0.3.0', 'v0.2.0', 'v0.1.1']) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stdout.trim(), 'v0.2.0') +}) + +test('the CLI prints nothing when there is no earlier release', () => { + const result = runCli(['--tag', 'v0.1.0'], []) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stdout.trim(), '', 'an empty stdout is what the workflow reads as "no base"') +}) + +test('the CLI fails closed on a target tag outside the grammar', () => { + const result = runCli(['--tag', 'refs/heads/main'], ['v0.2.0']) + assert.equal(result.status, 1) + assert.equal(result.stdout, '', 'nothing usable may reach stdout on a refusal') + assert.match(result.stderr, /::error::/) +}) + +test('the CLI refuses a missing --tag rather than defaulting', () => { + const result = runCli([], ['v0.2.0']) + assert.equal(result.status, 2) + assert.match(result.stderr, /usage/) +}) + +// ----------------------------------------------------------------------------- +// 5. The suite is actually run by the required gate +// ----------------------------------------------------------------------------- + +test('ci-required runs this suite beside the release workflow contract', () => { + const ciRequired = readFileSync(ciRequiredPath, 'utf8').replace(/\r\n/g, '\n') + assert.match( + ciRequired, + /node --test scripts\/ci\/release-desktop-dispatch\.test\.mjs/, + 'the dispatch contract is the block this suite belongs to', + ) + assert.match( + ciRequired, + /node --test scripts\/ci\/select-changelog-base\.test\.mjs/, + 'an unrun contract suite protects nothing', + ) +}) From 8a98c9601f0d677918297e6f12606f2a3b0d76ce Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 12:58:52 +0100 Subject: [PATCH 2/6] ci(release): rehearsal preview tag and workflow-revision tooling for the composer --- .github/workflows/release-desktop.yml | 181 ++++++++++++-- scripts/ci/release-desktop-dispatch.test.mjs | 250 ++++++++++++++++++- 2 files changed, 404 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 29c3842a8..07e8f7d2f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -27,7 +27,31 @@ # `--notes-file` on create and re-asserted on publish. A rehearsal dispatch # renders it too and uploads it as the `composed-page-body` artifact. # -# Tracked by: #535 (PKG-03), #1795, #1806, #1878, #2217, #2234 +# Three composer follow-ups (#2250): +# +# * `preview_tag` (rehearsal only) renders the page as a prospective tag, so a +# no-publish dispatch previews the real STABLE page instead of the +# `v0.0.0-dryrun+` RC fallback. It is a RENDER-ONLY output of +# resolve-source (`render_tag` / `render_prerelease`), read by compose-notes +# and by nothing that builds, names an asset or writes a Release. +# +# * The composer runs from a SECOND checkout of the WORKFLOW revision +# (`.workflow-tooling`, pinned to `github.workflow_sha`), because the release +# checkout is the TAGGED commit (#1795) and a pre-0.3 tag has no composer in +# its tree — re-dispatching `v0.2.0` (48c05e1dc) died with MODULE_NOT_FOUND +# after the whole Windows build. Only the page RENDERER moves: the ZIP and +# its SHA-256 are still produced from and verified against the tagged tree, +# and UPGRADING.md, `docs/releases/notes/.md` and the checksum are still +# read from the tagged checkout. A pre-0.3 tag that also lacks those release +# files still fails, but now at compose time with a named missing input +# rather than an opaque module error. +# +# * The changelog base is the newest stable release that sorts STRICTLY BEFORE +# the tag being built (`scripts/ci/select-changelog-base.mjs`), not the +# globally newest stable one by release date, and the listing that feeds it +# retries like every other release API call. +# +# Tracked by: #535 (PKG-03), #1795, #1806, #1878, #2217, #2234, #2250 # ============================================================================= name: Release Desktop @@ -39,6 +63,10 @@ on: description: "Existing version tag to build and PUBLISH (e.g. v0.1.1). Leave BLANK when dispatching from a BRANCH for a rehearsal: builds and smoke-tests the Windows x64 archive, uploads artifacts, and publishes nothing. Dispatching from a TAG ref publishes that tag even with this left blank." required: false type: string + preview_tag: + description: "REHEARSAL ONLY (e.g. v0.3.0). Renders the release page as if this tag existed, so a no-publish dispatch previews the real stable page instead of the v0.0.0-dryrun placeholder. It NEVER names, creates, touches or publishes a Release, and it never reaches the built archive name or the version stamped into the binaries. Ignored with a notice when this dispatch actually publishes." + required: false + type: string push: tags: - "v*" @@ -79,6 +107,10 @@ jobs: sha: ${{ steps.resolve.outputs.sha }} publish: ${{ steps.resolve.outputs.publish }} prerelease: ${{ steps.resolve.outputs.prerelease }} + # RENDER-ONLY (#2250). compose-notes is the single consumer; nothing that + # builds, names an asset or writes a Release ever reads these. + render_tag: ${{ steps.resolve.outputs.render_tag }} + render_prerelease: ${{ steps.resolve.outputs.render_prerelease }} steps: # Only the tag validator is needed here; nothing in this job writes to Git. - name: Checkout @@ -95,15 +127,22 @@ jobs: # close the assignment or trigger command substitution before the # grammar check runs. The job that consumes it holds `contents: write`. RAW_TAG: ${{ inputs.tag }} + # UNTRUSTED, same handling as RAW_TAG. It renders a page and never + # publishes one, but it still reaches file paths and a Markdown body, + # so it clears the same grammar gate before any use. + RAW_PREVIEW_TAG: ${{ inputs.preview_tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail raw_tag="${RAW_TAG:-}" + raw_preview_tag="${RAW_PREVIEW_TAG:-}" publish="false" prerelease="false" tag="" sha="" + render_tag="" + render_prerelease="false" # --- 1. Decide the tag, refusing anything outside the grammar ------- if [ -n "${raw_tag}" ]; then @@ -176,11 +215,44 @@ jobs: *) prerelease="false" ;; esac + # --- 5. Decide the RENDER tag for a rehearsal preview (#2250) ------ + # A blank-tag rehearsal resolves to v0.0.0-dryrun+, which carries + # a prerelease segment, so the composer always took the RC fallback and + # the uploaded preview was never the page a stable tag would render. + # `preview_tag` supplies a prospective tag for RENDERING only. + # + # It is deliberately a SEPARATE output. `tag`, `publish`, `prerelease`, + # the stamped product version, the archive name and every `gh release` + # call keep using the resolved tag above, so no value of `preview_tag` + # can create, adopt, rename or touch a Release: the create-release job + # is still gated on `publish`, which a preview tag never sets. + render_tag="${tag}" + if [ -n "${raw_preview_tag}" ]; then + if [ "${publish}" = "true" ]; then + # A notice rather than an error: this dispatch publishes a real + # tag, and failing it over an input that changes nothing about + # what gets published would cost a build for no safety gain. + printf '::notice::preview_tag %q is ignored — this dispatch publishes %s.\n' \ + "${raw_preview_tag}" "${tag}" + else + render_tag="$(bash scripts/ci/validate-release-tag.sh "${raw_preview_tag}")" + printf 'Rehearsal renders the release page as %s; nothing is published.\n' "${render_tag}" + fi + fi + # Derived from the render tag by the SAME grammar rule as step 4, so a + # preview of a stable tag exercises the stable fail-closed policy. + case "${render_tag}" in + *-*) render_prerelease="true" ;; + *) render_prerelease="false" ;; + esac + { printf 'tag=%s\n' "${tag}" printf 'sha=%s\n' "${sha}" printf 'publish=%s\n' "${publish}" printf 'prerelease=%s\n' "${prerelease}" + printf 'render_tag=%s\n' "${render_tag}" + printf 'render_prerelease=%s\n' "${render_prerelease}" } >> "${GITHUB_OUTPUT}" # The Markdown code-span delimiter is held in a variable and passed as a @@ -195,6 +267,7 @@ jobs: printf '| commit | %s%s%s |\n' "${bt}" "${sha}" "${bt}" printf '| publishes a release | %s%s%s |\n' "${bt}" "${publish}" "${bt}" printf '| published as a prerelease | %s%s%s |\n' "${bt}" "${prerelease}" "${bt}" + printf '| page rendered as (render only) | %s%s%s |\n' "${bt}" "${render_tag}" "${bt}" } >> "${GITHUB_STEP_SUMMARY}" # --------------------------------------------------------------------------- @@ -598,6 +671,39 @@ jobs: fi printf 'Release source commit verified: %s\n' "${actual_sha}" + # The workflow's own TOOLING, from the revision this workflow file came + # from (#2250 item 2). The release checkout above is the TAGGED commit by + # design (#1795), and a pre-0.3 tag has no composer in its tree, so a + # resumable re-publish of v0.2.0 died with MODULE_NOT_FOUND after the whole + # Windows build. Only the page RENDERER moves: the archive and its SHA-256 + # are still produced from, and verified against, the tagged tree, and every + # byte of release CONTENT below (UPGRADING.md, the curated notes, the + # checksum file) is still read from the tagged checkout. `workflow_sha` is + # the commit of the workflow file that is actually running, so the tooling + # can never come from a ref the dispatcher did not select. + - name: Checkout workflow tooling + uses: actions/checkout@v7 + with: + ref: ${{ github.workflow_sha }} + path: .workflow-tooling + persist-credentials: false + + - name: Verify the workflow tooling checkout carries the composer + shell: bash + env: + TOOLING_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + if [ ! -f .workflow-tooling/scripts/ci/compose-release-notes.mjs ]; then + printf '::error::The workflow tooling checkout has no scripts/ci/compose-release-notes.mjs; refusing to render a page.\n' + exit 1 + fi + if [ ! -f .workflow-tooling/scripts/ci/select-changelog-base.mjs ]; then + printf '::error::The workflow tooling checkout has no scripts/ci/select-changelog-base.mjs; refusing to guess a changelog base.\n' + exit 1 + fi + printf 'Workflow tooling checked out from %s.\n' "${TOOLING_SHA}" + - name: Setup Node uses: actions/setup-node@v7 with: @@ -618,10 +724,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ needs.resolve-source.outputs.tag }} - RELEASE_PRERELEASE: ${{ needs.resolve-source.outputs.prerelease }} RELEASE_PUBLISH: ${{ needs.resolve-source.outputs.publish }} + # The tag the PAGE is rendered for. Equal to RELEASE_TAG unless a + # rehearsal supplied preview_tag (#2250); render-only either way. + RENDER_TAG: ${{ needs.resolve-source.outputs.render_tag }} + RENDER_PRERELEASE: ${{ needs.resolve-source.outputs.render_prerelease }} run: | set -euo pipefail + # The archive that was actually BUILT, and whose .sha256 exists. It + # stays on RELEASE_TAG even under a preview: the checksum printed on + # the page must belong to the file the page names. asset="taskdeck-${RELEASE_TAG}-win-x64.zip" generated="generated-notes.json" @@ -634,20 +746,47 @@ jobs: # picks the previous release of ANY kind: once v0.3.0-rc.1 exists, # the stable v0.3.0 page would cover only rc.1 -> final and hide # everything since v0.2.0 from the people the stable page is for. - # The base is therefore the newest published, non-draft, - # NON-prerelease release, so both a stable page and an RC page span - # the whole gap since the last stable release. `gh release list` - # returns newest-first. - previous_tag="$(gh release list --repo "${GITHUB_REPOSITORY}" \ - --exclude-pre-releases --exclude-drafts --limit 1 \ - --json tagName --jq '.[0].tagName // empty')" + # The base is therefore a published, non-draft, NON-prerelease + # release, so both a stable page and an RC page span the whole gap + # since the last stable release. + # + # WHICH stable release is decided by semver, not by release date + # (#2250 item 3). `--limit 1` returned the globally newest stable + # one: re-running v0.3.0-rc.1 after v0.3.0 had shipped would have + # sent previous_tag_name=v0.3.0 and rendered a changelog running + # backwards. The list is now bounded but wide, and + # scripts/ci/select-changelog-base.mjs picks the newest entry that + # sorts STRICTLY BEFORE this tag. The listing gets the same bounded + # retry as generate-notes below; it used to be a single unretried + # call under `set -e`. + stable_tags="stable-tags.txt" + listed_ok=0 + for attempt in 1 2 3; do + if gh release list --repo "${GITHUB_REPOSITORY}" \ + --exclude-pre-releases --exclude-drafts --limit 100 \ + --json tagName --jq '.[].tagName' > "${stable_tags}"; then + listed_ok=1 + break + fi + printf '::warning::release list failed (attempt %s/3).\n' "${attempt}" + if [ "${attempt}" -lt 3 ]; then + sleep "$((attempt * 10))" + fi + done + if [ "${listed_ok}" -ne 1 ]; then + printf '::error::Could not list published releases after 3 attempts; refusing to guess the changelog base.\n' + exit 1 + fi + previous_tag="$(node .workflow-tooling/scripts/ci/select-changelog-base.mjs \ + --tag "${RELEASE_TAG}" --candidates "${stable_tags}")" generate_args=(-f "tag_name=${RELEASE_TAG}") - # The inequality guard covers a RE-RUN after this very tag was - # already published as a stable release: it would then be its own - # newest stable release, and a self-comparison is empty. + # The selector already excludes the target tag, so the inequality + # guard is redundant defence in depth: it keeps a RE-RUN after this + # very tag was published from ever becoming its own changelog base, + # even if the selection above were replaced. if [ -n "${previous_tag}" ] && [ "${previous_tag}" != "${RELEASE_TAG}" ]; then generate_args+=(-f "previous_tag_name=${previous_tag}") - printf 'Changelog base: %s (newest published stable release).\n' "${previous_tag}" + printf 'Changelog base: %s (newest stable release before %s).\n' "${previous_tag}" "${RELEASE_TAG}" else printf 'No earlier stable release found; letting GitHub infer the changelog base.\n' fi @@ -678,14 +817,22 @@ jobs: # Exits non-zero for a STABLE tag whose UPGRADING section or curated # highlights are missing, before anything is published; a release # candidate warns and falls back. - node scripts/ci/compose-release-notes.mjs \ - --tag "${RELEASE_TAG}" \ - --prerelease "${RELEASE_PRERELEASE}" \ + # + # The RENDERER comes from the workflow revision (#2250 item 2); every + # path it READS — UPGRADING.md, the curated notes, the checksum — is + # relative to the tagged checkout, so no content from a later revision + # can be published under an older tag. Under a rehearsal preview, + # RENDER_TAG selects which UPGRADING section and which notes file the + # page is proved against, which is what makes the preview a preview of + # the STABLE page rather than the dry-run RC fallback. + node .workflow-tooling/scripts/ci/compose-release-notes.mjs \ + --tag "${RENDER_TAG}" \ + --prerelease "${RENDER_PRERELEASE}" \ --repo "${GITHUB_REPOSITORY}" \ --asset "${asset}" \ --checksum-file "release-assets/${asset}.sha256" \ --upgrading UPGRADING.md \ - --notes "docs/releases/notes/${RELEASE_TAG}.md" \ + --notes "docs/releases/notes/${RENDER_TAG}.md" \ --generated-notes "${generated}" \ --out release-notes.md diff --git a/scripts/ci/release-desktop-dispatch.test.mjs b/scripts/ci/release-desktop-dispatch.test.mjs index 421f84ebd..0c372ab92 100644 --- a/scripts/ci/release-desktop-dispatch.test.mjs +++ b/scripts/ci/release-desktop-dispatch.test.mjs @@ -262,8 +262,8 @@ test('every checkout refuses to persist Git credentials', () => { const checkouts = workflow.match(/uses: actions\/checkout@[^\n]*\n(?: +[^\n]*\n)*/g) ?? [] assert.equal( checkouts.length, - 5, - 'resolve-source, build-frontend, build-backend, compose-notes, create-release', + 6, + 'resolve-source, build-frontend, build-backend, compose-notes (release source + workflow tooling), create-release', ) for (const block of checkouts) { assert.match(block, /persist-credentials: false/, `checkout without persist-credentials: false:\n${block}`) @@ -651,13 +651,18 @@ test('a dedicated job composes the page body and runs on the rehearsal path too' /^\s+if: needs\.resolve-source\.outputs\.publish == 'true'$/m, 'a rehearsal dispatch must reach the composer — previewing the page is its whole point', ) - assert.match(job, /node scripts\/ci\/compose-release-notes\.mjs/, 'the bash calls the tested script') + assert.match( + job, + /node \.workflow-tooling\/scripts\/ci\/compose-release-notes\.mjs/, + 'the bash calls the tested script, from the workflow revision (#2250 item 2)', + ) }) -test('the composer receives the resolved tag, prerelease decision and repo through env', () => { +test('the composer receives the render tag, prerelease decision and repo through env', () => { const job = jobBlock('compose-notes') assert.match(job, /RELEASE_TAG: \$\{\{ needs\.resolve-source\.outputs\.tag \}\}/) - assert.match(job, /RELEASE_PRERELEASE: \$\{\{ needs\.resolve-source\.outputs\.prerelease \}\}/) + assert.match(job, /RENDER_TAG: \$\{\{ needs\.resolve-source\.outputs\.render_tag \}\}/) + assert.match(job, /RENDER_PRERELEASE: \$\{\{ needs\.resolve-source\.outputs\.render_prerelease \}\}/) assert.match(job, /RELEASE_PUBLISH: \$\{\{ needs\.resolve-source\.outputs\.publish \}\}/) assert.match(job, /--repo "\$\{GITHUB_REPOSITORY\}"/, 'the repo is read from the runner, not hard-coded') assert.doesNotMatch(job, /\$\{\{ *inputs\./, 'the untrusted dispatch input never reaches this job') @@ -667,7 +672,7 @@ test('the composer is fed the checksum, UPGRADING.md and the curated notes for t const job = jobBlock('compose-notes') assert.match(job, /--checksum-file "release-assets\/\$\{asset\}\.sha256"/) assert.match(job, /--upgrading UPGRADING\.md/) - assert.match(job, /--notes "docs\/releases\/notes\/\$\{RELEASE_TAG\}\.md"/) + assert.match(job, /--notes "docs\/releases\/notes\/\$\{RENDER_TAG\}\.md"/) assert.match(job, /--out release-notes\.md/) assert.match( job, @@ -690,12 +695,27 @@ test('generate-notes is attempted only when a tag actually exists', () => { assert.match(job, /Rehearsal dispatch: no release tag exists/, 'the rehearsal branch says so in the log') }) -test('the changelog base is the newest published stable release, with bounded retries', () => { +// The base is the newest stable release that sorts STRICTLY BEFORE the tag +// being built, not the globally newest stable one (#2250 item 3). +// `gh release list --limit 1` ordered by RELEASE DATE, so re-running +// v0.3.0-rc.1 after v0.3.0 had shipped sent previous_tag_name=v0.3.0 and +// rendered a changelog that ran backwards. +test('the changelog base is the newest stable release before the target tag, with bounded retries', () => { const job = jobBlock('compose-notes') assert.match( job, - /gh release list --repo "\$\{GITHUB_REPOSITORY\}" \\\n\s+--exclude-pre-releases --exclude-drafts --limit 1/, - 'a prerelease must never become the base of the next stable page', + /gh release list --repo "\$\{GITHUB_REPOSITORY\}" \\\n\s+--exclude-pre-releases --exclude-drafts --limit 100/, + 'a prerelease must never become the base, and the candidate list is bounded but not truncated to one', + ) + assert.doesNotMatch( + job, + /--exclude-pre-releases --exclude-drafts --limit 1\b/, + 'the date-ordered single-row lookup is the defect and must not come back', + ) + assert.match( + job, + /node \.workflow-tooling\/scripts\/ci\/select-changelog-base\.mjs \\\n\s+--tag "\$\{RELEASE_TAG\}" --candidates "\$\{stable_tags\}"/, + 'the ordering is semver, decided by the unit-tested selector, not by release date', ) assert.match( job, @@ -708,9 +728,20 @@ test('the changelog base is the newest published stable release, with bounded re 'a first release, and a re-run of an already-published stable tag, omit the field', ) assert.match(job, /letting GitHub infer the changelog base/, 'the omission is logged, not silent') - assert.match(job, /for attempt in 1 2 3; do/, 'the API call retries a bounded number of times') assert.match(job, /sleep "\$\(\(attempt \* 10\)\)"/, 'the same 10/20s backoff as the asset upload') assert.match(job, /generate-notes failed after 3 attempts[\s\S]{0,120}?exit 1/, 'exhausted retries fail closed') + + const retryLoops = job.match(/for attempt in 1 2 3; do/g) ?? [] + assert.equal( + retryLoops.length, + 2, + 'the release listing gets the same bounded retry as generate-notes — it was unretried under set -e', + ) + assert.match( + job, + /Could not list published releases after 3 attempts[\s\S]{0,120}?exit 1/, + 'an exhausted listing fails closed instead of guessing a base', + ) }) // The bug this replaces a decorative assertion for: `actions/download-artifact` @@ -823,3 +854,202 @@ test('the publish flip carries the prerelease flag in the same edit', () => { 'the create and publish steps each read the decision from resolve-source through the environment', ) }) + +// ----------------------------------------------------------------------------- +// 9. Rehearsal preview tag (#2250 item 1) +// +// A blank-tag no-publish dispatch resolves to `v0.0.0-dryrun+`, which +// carries a prerelease segment, so the composer always took the RC fallback and +// the uploaded `composed-page-body` was never a preview of the page a stable tag +// would render. `preview_tag` supplies a RENDER-ONLY tag. It is untrusted +// dispatch text with the same reach as `inputs.tag` into the composer, so it +// clears the same grammar gate; and it must not be readable by anything that +// publishes. +// ----------------------------------------------------------------------------- + +test('the rehearsal preview tag reaches Bash only through a step env var', () => { + assert.match( + workflow, + /^\s+RAW_PREVIEW_TAG: \$\{\{ inputs\.preview_tag \}\}$/m, + 'inputs.preview_tag must be bound to an env var, not spliced into a run block', + ) + + const uses = workflow.match(/\$\{\{ *inputs\.preview_tag[^}]*\}\}/g) ?? [] + assert.deepEqual( + uses, + ['${{ inputs.preview_tag }}'], + 'inputs.preview_tag may be referenced exactly once, by the RAW_PREVIEW_TAG env binding', + ) +}) + +test('the preview tag input is declared rehearsal-only and never required', () => { + const inputs = /\n workflow_dispatch:\n inputs:\n([\s\S]*?)\n push:\n/.exec(workflow) + assert.ok(inputs, 'the dispatch inputs block must exist') + const block = inputs[1] + assert.match(block, /^ preview_tag:$/m, 'the input is named preview_tag') + const declaration = /\n preview_tag:\n([\s\S]*?)(?=\n [a-z_]+:\n|$)/.exec(`\n${block}`) + assert.ok(declaration, 'preview_tag must carry its own declaration') + assert.match(declaration[1], /required: false/, 'a rehearsal input is never required') + assert.match(declaration[1], /type: string/) + assert.match( + declaration[1], + /never|NEVER/, + 'the description must say the input never publishes — it is the only place a dispatcher reads', + ) + assert.match(declaration[1], /REHEARSAL|rehearsal/, 'the description must name the rehearsal path') +}) + +test('the preview tag clears the same grammar gate as a real release tag', () => { + const job = jobBlock('resolve-source') + assert.match( + job, + /render_tag="\$\(bash scripts\/ci\/validate-release-tag\.sh "\$\{raw_preview_tag\}"\)"/, + 'the preview tag is validated by the shared grammar gate, not trusted as given', + ) + assert.match( + job, + /raw_preview_tag="\$\{RAW_PREVIEW_TAG:-\}"/, + 'the untrusted value is assigned inside Bash from the environment', + ) + const validatorCalls = job.match(/bash scripts\/ci\/validate-release-tag\.sh/g) ?? [] + assert.ok( + validatorCalls.length >= 4, + 'the dispatch input, the pushed tag ref, the final tag and the preview tag are each validated', + ) +}) + +test('render_tag falls back to the resolved tag and is ignored on a publishing dispatch', () => { + const job = jobBlock('resolve-source') + const fallbackAt = job.indexOf('render_tag="${tag}"') + const guardAt = job.indexOf('if [ -n "${raw_preview_tag}" ]; then') + assert.ok(fallbackAt !== -1, 'render_tag defaults to the resolved release tag') + assert.ok(guardAt !== -1, 'the preview tag is only consulted when one was supplied') + assert.ok(fallbackAt < guardAt, 'the fallback is assigned before any preview tag can override it') + assert.match( + job, + /if \[ "\$\{publish\}" = "true" \]; then\n\s+#[\s\S]{0,400}?printf '::notice::preview_tag/, + 'a publishing dispatch ignores the input with a notice instead of rendering an unpublished tag', + ) + assert.match( + job, + /case "\$\{render_tag\}" in\n\s+\*-\*\) render_prerelease="true" ;;\n\s+\*\)\s+render_prerelease="false" ;;\n\s+esac/, + 'the render prerelease flag is derived from render_tag by the same grammar rule as the real tag', + ) +}) + +test('resolve-source publishes the render tag and its prerelease decision as outputs', () => { + const job = jobBlock('resolve-source') + assert.match(job, /render_tag: \$\{\{ steps\.resolve\.outputs\.render_tag \}\}/) + assert.match(job, /render_prerelease: \$\{\{ steps\.resolve\.outputs\.render_prerelease \}\}/) + assert.match(job, /printf 'render_tag=%s\\n' "\$\{render_tag\}"/, 'the output is written, not only computed') + assert.match(job, /printf 'render_prerelease=%s\\n' "\$\{render_prerelease\}"/) +}) + +test('no publishing step reads the preview tag or the render tag', () => { + // The whole safety claim of #2250 item 1 is that a preview_tag can never + // create or touch a Release, so the render outputs must be invisible to every + // job that holds a write path to one. + for (const job of ['build-frontend', 'build-backend', 'create-release']) { + const block = jobBlock(job) + assert.doesNotMatch(block, /render_tag|render_prerelease|RENDER_TAG|RENDER_PRERELEASE/, `${job} must not read the render tag`) + assert.doesNotMatch(block, /preview_tag|RAW_PREVIEW_TAG/, `${job} must not read the preview tag`) + } + + const consumers = workflow.match(/needs\.resolve-source\.outputs\.render_[a-z_]+/g) ?? [] + const composeConsumers = jobBlock('compose-notes').match(/needs\.resolve-source\.outputs\.render_[a-z_]+/g) ?? [] + assert.equal( + consumers.length, + composeConsumers.length, + 'compose-notes is the only consumer of the render outputs', + ) + assert.equal(composeConsumers.length, 2, 'compose-notes reads exactly render_tag and render_prerelease') + + // The build tag, the asset names and the provenance stay on the resolved tag. + assert.match( + jobBlock('build-backend'), + /RELEASE_TAG: \$\{\{ needs\.resolve-source\.outputs\.tag \}\}/, + 'the stamped version and the archive name come from the resolved tag', + ) + assert.match( + workflow, + /if: needs\.resolve-source\.outputs\.publish == 'true'/, + 'the publish gate is still the publish decision, which a preview tag never changes', + ) +}) + +test('the composer renders the preview tag while the asset stays the built one', () => { + const job = jobBlock('compose-notes') + assert.match(job, /--tag "\$\{RENDER_TAG\}"/, 'the page is rendered for the tag being previewed') + assert.match(job, /--prerelease "\$\{RENDER_PRERELEASE\}"/, 'the RC-vs-stable policy follows the render tag') + assert.match( + job, + /asset="taskdeck-\$\{RELEASE_TAG\}-win-x64\.zip"/, + 'the checksum on the page must belong to the archive that was actually built', + ) +}) + +// ----------------------------------------------------------------------------- +// 10. Legacy-tag re-dispatch (#2250 item 2) +// +// The release checkout is pinned to the TAGGED commit (#1795), and the composer +// was run from it. `v0.2.0` predates the composer, so re-dispatching that tag +// died with MODULE_NOT_FOUND after the whole Windows build. The workflow's own +// tooling now comes from the workflow revision in a separate checkout, while +// every byte of release CONTENT — UPGRADING.md, the curated notes and the +// checksum of the built archive — still comes from the tagged tree. +// ----------------------------------------------------------------------------- + +test('workflow tooling is checked out from the workflow revision into its own path', () => { + const job = jobBlock('compose-notes') + assert.match( + job, + /ref: \$\{\{ github\.workflow_sha \}\}/, + 'the tooling checkout is pinned to the commit the running workflow file came from', + ) + assert.match(job, /path: \.workflow-tooling/, 'the tooling lands beside, never on top of, the release source') + assert.match( + job, + /ref: \$\{\{ github\.workflow_sha \}\}\n\s+path: \.workflow-tooling\n\s+persist-credentials: false/, + 'the second checkout refuses to persist credentials, exactly like the first', + ) + + const releaseCheckoutAt = job.indexOf('ref: ${{ needs.resolve-source.outputs.sha }}') + const verifyAt = job.indexOf('Verify checkout matches the resolved release commit') + const toolingAt = job.indexOf('ref: ${{ github.workflow_sha }}') + assert.ok(releaseCheckoutAt !== -1 && verifyAt !== -1 && toolingAt !== -1) + assert.ok( + releaseCheckoutAt < verifyAt && verifyAt < toolingAt, + 'the release source is checked out and verified before any tooling is added to the workspace', + ) +}) + +test('the tooling checkout is proved to carry the composer before the build is spent', () => { + const job = jobBlock('compose-notes') + assert.match( + job, + /if \[ ! -f \.workflow-tooling\/scripts\/ci\/compose-release-notes\.mjs \]/, + 'a missing composer is named explicitly rather than surfacing as MODULE_NOT_FOUND', + ) + assert.match(job, /exit 1/, 'the guard fails closed') +}) + +test('release content is still read from the tagged checkout, not the tooling one', () => { + const job = jobBlock('compose-notes') + const renderStep = job.slice(job.indexOf('Render the release page body')) + assert.match(renderStep, /--upgrading UPGRADING\.md/, 'UPGRADING.md is the tagged tree, unprefixed') + assert.match( + renderStep, + /--notes "docs\/releases\/notes\/\$\{RENDER_TAG\}\.md"/, + 'the curated notes are the tagged tree, unprefixed', + ) + assert.match( + renderStep, + /--checksum-file "release-assets\/\$\{asset\}\.sha256"/, + 'the checksum is the one built from the tagged commit', + ) + assert.doesNotMatch( + renderStep, + /--upgrading \.workflow-tooling|--notes "\.workflow-tooling|--checksum-file "\.workflow-tooling/, + 'release content must never be lifted from a later revision and published under an older tag', + ) +}) From b84abdb93a2b0ac560ac3f828b42847caf1146b5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 13:19:26 +0100 Subject: [PATCH 3/6] ci(release): fail closed on a preview_tag that publishes, a full listing window and an unpeeled tooling HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release-desktop.yml | 47 ++++++++-- scripts/ci/release-desktop-dispatch.test.mjs | 96 ++++++++++++++++++-- 2 files changed, 128 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 07e8f7d2f..bf16662f0 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -33,7 +33,9 @@ # no-publish dispatch previews the real STABLE page instead of the # `v0.0.0-dryrun+` RC fallback. It is a RENDER-ONLY output of # resolve-source (`render_tag` / `render_prerelease`), read by compose-notes -# and by nothing that builds, names an asset or writes a Release. +# and by nothing that builds, names an asset or writes a Release. Supplying +# it on a dispatch that PUBLISHES is refused in resolve-source, before any +# build starts: the two inputs then state two different intents. # # * The composer runs from a SECOND checkout of the WORKFLOW revision # (`.workflow-tooling`, pinned to `github.workflow_sha`), because the release @@ -64,7 +66,7 @@ on: required: false type: string preview_tag: - description: "REHEARSAL ONLY (e.g. v0.3.0). Renders the release page as if this tag existed, so a no-publish dispatch previews the real stable page instead of the v0.0.0-dryrun placeholder. It NEVER names, creates, touches or publishes a Release, and it never reaches the built archive name or the version stamped into the binaries. Ignored with a notice when this dispatch actually publishes." + description: "REHEARSAL ONLY (e.g. v0.3.0). Renders the release page as if this tag existed, so a no-publish dispatch previews the real stable page instead of the v0.0.0-dryrun placeholder. It NEVER names, creates, touches or publishes a Release, and it never reaches the built archive name or the version stamped into the binaries. A dispatch that also PUBLISHES (a tag input, or a tag ref) is REFUSED before any build runs — use exactly one of the two." required: false type: string push: @@ -229,11 +231,16 @@ jobs: render_tag="${tag}" if [ -n "${raw_preview_tag}" ]; then if [ "${publish}" = "true" ]; then - # A notice rather than an error: this dispatch publishes a real - # tag, and failing it over an input that changes nothing about - # what gets published would cost a build for no safety gain. - printf '::notice::preview_tag %q is ignored — this dispatch publishes %s.\n' \ + # Fail closed, and fail here. A dispatch carrying both inputs is + # stating two different intents — preview THIS tag, publish THAT + # one — and the refusal is the only mechanical signal that the + # dispatcher's intent and the dispatcher's action disagree; + # ignoring the input publishes under a silent assumption about + # which of the two was meant. Refusing costs nothing: this is the + # first job, it builds nothing, and every build job waits on it. + printf '::error::preview_tag %q was supplied on a dispatch that PUBLISHES %s. preview_tag is rehearsal-only; re-dispatch with exactly one of tag and preview_tag.\n' \ "${raw_preview_tag}" "${tag}" + exit 1 else render_tag="$(bash scripts/ci/validate-release-tag.sh "${raw_preview_tag}")" printf 'Rehearsal renders the release page as %s; nothing is published.\n' "${render_tag}" @@ -702,7 +709,18 @@ jobs: printf '::error::The workflow tooling checkout has no scripts/ci/select-changelog-base.mjs; refusing to guess a changelog base.\n' exit 1 fi - printf 'Workflow tooling checked out from %s.\n' "${TOOLING_SHA}" + # `github.workflow_sha` is the tag OBJECT id on an annotated-tag push. + # actions/checkout is expected to peel it to the commit, and every + # claim above ("the tooling comes from the workflow revision") rests on + # that, but nothing here proved it. Prove it, and log what HEAD really + # resolved to beside what was asked for. + tooling_commit="$(git -C .workflow-tooling rev-parse --verify 'HEAD^{commit}' 2>/dev/null || true)" + if [ -z "${tooling_commit}" ]; then + printf '::error::The workflow tooling checkout for %s has no resolvable HEAD commit; refusing to render a page.\n' \ + "${TOOLING_SHA}" + exit 1 + fi + printf 'Workflow tooling checked out from %s (HEAD commit %s).\n' "${TOOLING_SHA}" "${tooling_commit}" - name: Setup Node uses: actions/setup-node@v7 @@ -760,10 +778,16 @@ jobs: # retry as generate-notes below; it used to be a single unretried # call under `set -e`. stable_tags="stable-tags.txt" + # The window is wide, and named once so the truncation check below + # cannot drift from the limit it checks. A page that comes back + # exactly full is indistinguishable from a truncated one, and the + # base is chosen only from what was listed, so it is refused rather + # than trusted. + stable_tag_limit=200 listed_ok=0 for attempt in 1 2 3; do if gh release list --repo "${GITHUB_REPOSITORY}" \ - --exclude-pre-releases --exclude-drafts --limit 100 \ + --exclude-pre-releases --exclude-drafts --limit "${stable_tag_limit}" \ --json tagName --jq '.[].tagName' > "${stable_tags}"; then listed_ok=1 break @@ -777,6 +801,13 @@ jobs: printf '::error::Could not list published releases after 3 attempts; refusing to guess the changelog base.\n' exit 1 fi + listed_count="$(wc -l < "${stable_tags}" | tr -d '[:space:]')" + printf 'Listed %s stable releases (window %s).\n' "${listed_count}" "${stable_tag_limit}" + if [ "${listed_count}" -eq "${stable_tag_limit}" ]; then + printf '::error::The stable release listing came back exactly full (%s rows), so older stable releases may have been truncated out of the candidate set and the changelog base cannot be trusted; widen the listing window.\n' \ + "${stable_tag_limit}" + exit 1 + fi previous_tag="$(node .workflow-tooling/scripts/ci/select-changelog-base.mjs \ --tag "${RELEASE_TAG}" --candidates "${stable_tags}")" generate_args=(-f "tag_name=${RELEASE_TAG}") diff --git a/scripts/ci/release-desktop-dispatch.test.mjs b/scripts/ci/release-desktop-dispatch.test.mjs index 0c372ab92..f1b997bc8 100644 --- a/scripts/ci/release-desktop-dispatch.test.mjs +++ b/scripts/ci/release-desktop-dispatch.test.mjs @@ -208,6 +208,14 @@ function jobBlock(name) { return next === -1 ? rest : rest.slice(0, next) } +function stepBlock(job, stepName) { + const start = job.indexOf(`- name: ${stepName}\n`) + assert.notEqual(start, -1, `step ${stepName} must exist`) + const rest = job.slice(start) + const next = rest.indexOf('\n - name: ') + return next === -1 ? rest : rest.slice(0, next) +} + function matrixBlock(jobName) { const job = jobBlock(jobName) const start = job.indexOf('\n matrix:\n') @@ -704,9 +712,24 @@ test('the changelog base is the newest stable release before the target tag, wit const job = jobBlock('compose-notes') assert.match( job, - /gh release list --repo "\$\{GITHUB_REPOSITORY\}" \\\n\s+--exclude-pre-releases --exclude-drafts --limit 100/, + /gh release list --repo "\$\{GITHUB_REPOSITORY\}" \\\n\s+--exclude-pre-releases --exclude-drafts --limit "\$\{stable_tag_limit\}"/, 'a prerelease must never become the base, and the candidate list is bounded but not truncated to one', ) + assert.match( + job, + /stable_tag_limit=200\b/, + 'the candidate window is 200 stable releases, named once so the truncation check cannot drift from the limit it checks', + ) + assert.match( + job, + /if \[ "\$\{listed_count\}" -eq "\$\{stable_tag_limit\}" \]; then[\s\S]{0,500}?exit 1/, + 'a listing that comes back exactly full may have truncated older stable releases out of the candidate set, so it fails closed', + ) + assert.match( + job, + /::error::[^\n]*widen the listing window/, + 'the truncation refusal says what has to change', + ) assert.doesNotMatch( job, /--exclude-pre-releases --exclude-drafts --limit 1\b/, @@ -918,7 +941,7 @@ test('the preview tag clears the same grammar gate as a real release tag', () => ) }) -test('render_tag falls back to the resolved tag and is ignored on a publishing dispatch', () => { +test('render_tag falls back to the resolved tag, and a publishing dispatch that also previews is refused', () => { const job = jobBlock('resolve-source') const fallbackAt = job.indexOf('render_tag="${tag}"') const guardAt = job.indexOf('if [ -n "${raw_preview_tag}" ]; then') @@ -927,9 +950,33 @@ test('render_tag falls back to the resolved tag and is ignored on a publishing d assert.ok(fallbackAt < guardAt, 'the fallback is assigned before any preview tag can override it') assert.match( job, - /if \[ "\$\{publish\}" = "true" \]; then\n\s+#[\s\S]{0,400}?printf '::notice::preview_tag/, - 'a publishing dispatch ignores the input with a notice instead of rendering an unpublished tag', + /if \[ "\$\{publish\}" = "true" \]; then\n\s+#[\s\S]{0,900}?printf '::error::preview_tag[\s\S]{0,400}?exit 1/, + 'a dispatch that both publishes and previews states two conflicting intents and is refused, not silently narrowed', + ) + assert.doesNotMatch( + job, + /::notice::preview_tag/, + 'ignoring the input with a notice leaves the disagreement between intent and action with no mechanical signal', ) + assert.match( + job, + /printf '::error::preview_tag[^\n]*%s[\s\S]{0,300}?"\$\{raw_preview_tag\}" "\$\{tag\}"/, + 'the refusal names both inputs, so the dispatcher sees which two disagreed', + ) + // The refusal is free: resolve-source is the first job, builds nothing, and + // every job that does build waits on it. + assert.doesNotMatch( + job, + /\n {4}needs:/, + 'resolve-source depends on nothing, so refusing there costs zero build minutes', + ) + for (const dependent of ['build-frontend', 'build-backend', 'compose-notes', 'create-release']) { + assert.match( + jobBlock(dependent), + /\n {4}needs: (resolve-source\b|\[resolve-source[,\]])/, + `${dependent} waits on resolve-source, so nothing is built before the refusal lands`, + ) + } assert.match( job, /case "\$\{render_tag\}" in\n\s+\*-\*\) render_prerelease="true" ;;\n\s+\*\)\s+render_prerelease="false" ;;\n\s+esac/, @@ -1023,14 +1070,49 @@ test('workflow tooling is checked out from the workflow revision into its own pa ) }) -test('the tooling checkout is proved to carry the composer before the build is spent', () => { +// NOT "before the build is spent": compose-notes needs build-backend, so this +// guard runs after the whole Windows build. What it does prove is that a missing +// composer is NAMED before node is invoked, instead of surfacing as an opaque +// MODULE_NOT_FOUND — and that the tooling ref really peeled to a commit, which +// `github.workflow_sha` does not guarantee on an annotated-tag push. +test('the tooling guard names a missing composer explicitly before the composer is invoked', () => { const job = jobBlock('compose-notes') + const guard = stepBlock(job, 'Verify the workflow tooling checkout carries the composer') assert.match( - job, + guard, /if \[ ! -f \.workflow-tooling\/scripts\/ci\/compose-release-notes\.mjs \]/, 'a missing composer is named explicitly rather than surfacing as MODULE_NOT_FOUND', ) - assert.match(job, /exit 1/, 'the guard fails closed') + assert.match( + guard, + /if \[ ! -f \.workflow-tooling\/scripts\/ci\/select-changelog-base\.mjs \]/, + 'the changelog selector is named the same way', + ) + // Anchored to the guard's OWN run block: compose-notes carries several + // unrelated `exit 1` lines, so matching the whole job would pass with the + // guard deleted. + assert.match(guard, /exit 1/, 'the guard itself fails closed, not some other step in the job') + + assert.match( + guard, + /git -C \.workflow-tooling rev-parse --verify 'HEAD\^\{commit\}'/, + 'the tooling checkout is proved to sit on a real commit, not an unpeeled annotated-tag object', + ) + assert.match( + guard, + /if \[ -z "\$\{tooling_commit\}" \]; then[\s\S]{0,400}?exit 1/, + 'an unresolvable tooling HEAD fails closed instead of rendering from an unknown tree', + ) + assert.match( + guard, + /printf 'Workflow tooling checked out from %s \(HEAD commit %s\)[\s\S]{0,200}?"\$\{TOOLING_SHA\}" "\$\{tooling_commit\}"/, + 'the resolved commit is printed next to the requested workflow_sha', + ) + + const guardAt = job.indexOf('Verify the workflow tooling checkout carries the composer') + const composerAt = job.indexOf('node .workflow-tooling/scripts/ci/compose-release-notes.mjs') + assert.ok(guardAt !== -1 && composerAt !== -1) + assert.ok(guardAt < composerAt, 'the guard runs before the composer it guards is invoked') }) test('release content is still read from the tagged checkout, not the tooling one', () => { From 6b591ebedf6a3166a8bf4e93898c6dec18e939c7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 13:19:27 +0100 Subject: [PATCH 4/6] docs(ops): state the semver changelog base and the preview_tag rehearsal 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. --- docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md index f6c29b788..b12c0730b 100644 --- a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md +++ b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md @@ -85,11 +85,18 @@ pointer at `UPGRADING.md`. A missing or mismatched checksum fails either way. The `compose-notes` job runs on the rehearsal path too and uploads what it rendered as the **`composed-page-body`** artifact, so a `no-publish` dispatch previews the exact page before a tag is cut (the changelog section is a placeholder -there, because `generate-notes` needs a tag that already exists). That artifact name must not match the `release-*` pattern +there, because `generate-notes` needs a tag that already exists). Left blank, a rehearsal resolves to +`v0.0.0-dryrun+`, which carries a prerelease segment and so renders the release-candidate fallback; the optional +`preview_tag` dispatch input renders the page as the prospective **stable** tag instead. It is render-only — it never +names, creates, touches or publishes a Release — and supplying it on a dispatch that actually publishes is REFUSED in +`resolve-source`, before any build runs, because the two inputs then state two different intents. +That artifact name must not match the `release-*` pattern `create-release` uses to collect the built assets — `download-artifact` matches it with minimatch, and a matching name would have the rendered Markdown published as a stray asset beside the ZIP; the dispatch suite asserts it with a real glob match. -On the publish path the changelog base is stated explicitly as the newest published **stable** release, so a stable page -always spans the whole gap since the last stable release rather than only the last release candidate. +On the publish path the changelog base is stated explicitly as the newest published **stable** release that sorts +strictly before the tag being built by semver — not the globally newest stable one by release date — so a stable page +always spans the whole gap since the last stable release rather than only the last release candidate, and re-running an +older tag after a newer one has shipped cannot render a changelog that runs backwards. `create-release` downloads that artifact by name, refuses an empty or button-less body, passes it to `gh release create --notes-file`, and re-asserts it in the same `gh release edit` that clears the draft flag — which is what keeps the resumable adopt path ([#1806](https://github.com/Chris0Jeky/Taskdeck/issues/1806)) From 64cd68f582c3d6b211b8c39f87a5ec648de95b13 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 13:28:25 +0100 Subject: [PATCH 5/6] docs(ops): name the composed-page-body artifact where its glob rule is stated --- docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md index b12c0730b..c63d988b1 100644 --- a/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md +++ b/docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md @@ -90,7 +90,7 @@ there, because `generate-notes` needs a tag that already exists). Left blank, a `preview_tag` dispatch input renders the page as the prospective **stable** tag instead. It is render-only — it never names, creates, touches or publishes a Release — and supplying it on a dispatch that actually publishes is REFUSED in `resolve-source`, before any build runs, because the two inputs then state two different intents. -That artifact name must not match the `release-*` pattern +The `composed-page-body` artifact name must not match the `release-*` pattern `create-release` uses to collect the built assets — `download-artifact` matches it with minimatch, and a matching name would have the rendered Markdown published as a stray asset beside the ZIP; the dispatch suite asserts it with a real glob match. On the publish path the changelog base is stated explicitly as the newest published **stable** release that sorts From 905f82a629efc219e6cce086d56279aae353118c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 14:33:11 +0100 Subject: [PATCH 6/6] ci(release-desktop): pin the tooling checkout to the same SHA as the other checkouts (CI-11 guard) --- .github/workflows/release-desktop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 4c198e5bb..a6c593d3e 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -703,7 +703,7 @@ jobs: # the commit of the workflow file that is actually running, so the tooling # can never come from a ref the dispatcher did not select. - name: Checkout workflow tooling - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.workflow_sha }} path: .workflow-tooling