diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs new file mode 100644 index 0000000..d6bbee4 --- /dev/null +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -0,0 +1,414 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +"use strict"; + +/** + * The nightly build-info region on the changesets "Version Packages" pull + * request. + * + * After a nightly publishes, the open `changeset-release/main` pull request + * gains a region naming the build a reviewer can install to try the work the + * pending changesets describe: + * + * + * ### Build Info + * `npx @taskless/cli-nightly@0.11.0-20260818123456x05b3c88` + * + * **Built from:** 05b3c88 + * **Built at:** 2026-08-18 12:34:56 + * + * + * EVERY FACT IN THAT REGION COMES FROM THE STAMPED VERSION, and there is no + * second source for any of them. The version is stamped exactly once per run + * (nightly-pack.cjs `--print-version`) because the build bakes it into the + * skills the tarball ships; re-reading the clock here would print a "Built at" + * that disagrees with the version printed one line above it, and re-reading + * `git rev-parse` would print a sha the published package does not carry. The + * version already encodes both — `-x` — so this + * file parses them back out rather than being handed a second opinion. + * + * THE REGION IS REMOVED AND RE-APPENDED, never edited in place. Deleting it is + * a supported thing for a human to do, and the next nightly puts it back at the + * END of the body — which is where it belongs regardless of where a previous + * one sat, so a body someone has reordered converges instead of accumulating. + * That also makes the upsert idempotent by construction: N publishes leave one + * region, not N. + * + * It coexists with `` … ``, which + * stack-breadcrumb.yml may be maintaining on the same body. The two are keyed + * on different names and neither pattern can match the other's markers; this + * one never rewrites a region it does not own. + * + * THE CONVERSE DOES NOT HOLD, AND CANNOT BE FIXED FROM HERE. If the Version + * Packages pull request is ever part of a stack that carries `` + * regions, stack-breadcrumb.cjs's `canonicalizeBody` re-lays the whole body as + * breadcrumb → description → carried regions. Its `ownDescription` strips only + * the regions IT owns, so this one travels inside "description" and lands + * ABOVE the carried blocks — no longer at the end. Nothing here runs at that + * moment, so "at the end" is a property of each write rather than of the body + * for all time (the spec says so in those words). The next publish moves the + * region back, which is the same self-healing the lost-update window relies on + * — see the workflow header. Teaching the other canonicalizer about this + * region would be the real fix, and it belongs in that file, on a change that + * can test it there. + * + * There is NO GitHub I/O here. The workflow fetches the pull request list with + * `gh api` and applies the body with `gh api -X PATCH` (never `gh pr edit` — + * its GraphQL path is broken by GitHub's Projects-classic deprecation), so this + * file stays zero-dependency CommonJS that `node --test` can exercise directly. + * + * Usage: + * + * node .github/scripts/nightly-breadcrumb.cjs \ + * --version --pulls --out + * + * `--pulls` is the JSON body of `GET /repos/{owner}/{repo}/pulls` filtered to + * the head branch. AN EMPTY LIST IS SUCCESS: `changeset-release/main` only + * exists while changesets are pending, and a nightly can publish in the seconds + * before changesets opens it. A cosmetic breadcrumb must never fail a run that + * already published to npm. A failed API call is a different thing entirely and + * is left to fail — the workflow lets `gh api` exit non-zero rather than + * folding "the query returned nothing" and "the query did not run" together. + * + * Writes `pull_number` and `changed` to $GITHUB_OUTPUT when it is set, and the + * new body to `--out` only when something actually changed. + */ + +const { appendFileSync, readFileSync, writeFileSync } = require("node:fs"); +const { resolve } = require("node:path"); + +/** The package a nightly is published under — NOT the released `@taskless/cli`. */ +const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; + +/** The head branch changesets opens its "Version Packages" pull request from. */ +const VERSION_PR_BRANCH = "changeset-release/main"; + +/** The base branch that pull request targets — matched, not assumed. */ +const DEFAULT_BRANCH = "main"; + +const REGION_OPEN = ""; +const REGION_CLOSE = ""; + +/** + * The install line inside a region, which is where the version a previous + * publish wrote can be read back from. Built from `NIGHTLY_PACKAGE` rather than + * spelled out, so renaming the package cannot leave the reader looking for a + * name the renderer no longer writes. + */ +const INSTALL_LINE_PATTERN = new RegExp( + `\`npx ${NIGHTLY_PACKAGE.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`)}@([^\`\\s]+)\`` +); + +/** + * The whole region, opening marker through closing marker. Non-greedy, so two + * regions in a hand-mangled body are removed one at a time rather than + * swallowing everything between the first open and the last close. + */ +const REGION_PATTERN = /[\S\s]*?/; + +/** + * Every region TOGETHER WITH the blank lines around it — the seam that removing + * it leaves behind. A SEPARATE object rather than a `g` flag on the one above, + * because a global regex carries `lastIndex` between `test()` calls and would + * answer `hasRegion` differently on alternate invocations. + * + * Capturing the surrounding newlines is what keeps this from touching prose. A + * blanket `\n{3,}` → `\n\n` pass over the whole body would also collapse an + * intentional run of blank lines somewhere else in the description — on every + * publish, silently rewriting text this file does not own. + */ +const REGION_SEAM_PATTERN = new RegExp(`\\n*${REGION_PATTERN.source}\\n*`, "g"); + +/** + * The stamped prerelease identifier, anchored to the end: `-<14 digits>x`. + * Same grammar nightly-pack.cjs writes (design D3), read in the other + * direction. + */ +const STAMP_PATTERN = /-(\d{14})x([0-9a-f]{7,40})$/; + +/** + * Split a stamped nightly version back into the two facts it encodes. + * + * Throws rather than degrading: a version this cannot parse is not a nightly + * version, and rendering "Built at: unknown" next to an install line would put + * a plausible-looking breadcrumb on a pull request describing a build nobody + * can account for. + */ +function parseStampedVersion(version) { + const match = STAMP_PATTERN.exec(String(version ?? "")); + if (!match) { + throw new Error( + `not a stamped nightly version: ${JSON.stringify(version)} (expected -x)` + ); + } + const [, stamp, sha] = match; + const builtAt = [ + `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}`, + `${stamp.slice(8, 10)}:${stamp.slice(10, 12)}:${stamp.slice(12, 14)}`, + ].join(" "); + // The stamp is UTC by construction (formatStampTimestamp uses toISOString), + // so this is a reformat, not a conversion — no clock and no timezone is + // consulted anywhere in this file. + const parsed = new Date(`${builtAt.replace(" ", "T")}Z`); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`stamped version carries an impossible time: ${version}`); + } + // `stamp` is the raw 14 digits: fixed-width and UTC, so a lexical compare of + // two of them orders the builds (see isNewerBuild). + return { builtAt, shortSha: sha, stamp }; +} + +/** Render the region for `version`, markers included, with no trailing newline. */ +function renderRegion(version) { + const { builtAt, shortSha } = parseStampedVersion(version); + return [ + REGION_OPEN, + "### Build Info", + `\`npx ${NIGHTLY_PACKAGE}@${version}\``, + "", + `**Built from:** ${shortSha}`, + `**Built at:** ${builtAt}`, + REGION_CLOSE, + ].join("\n"); +} + +/** Does `body` already carry a nightly region? */ +function hasRegion(body) { + return REGION_PATTERN.test(String(body ?? "")); +} + +/** + * Strip every nightly region from `body`, touching ONLY the whitespace at the + * seam the removal leaves behind. + * + * The normalization is scoped to the match, not applied to the body: the + * pattern eats the blank lines on either side of the region and the replacer + * decides what belongs there — a blank line when the region sat between two + * pieces of prose, nothing when it sat at the top or the bottom. Prose + * elsewhere is never rewritten, so an intentional run of blank lines in the + * description survives every republish. (A blanket `\n{3,}` → `\n\n` pass would + * collapse it, silently, on a body this file does not own.) + * + * A body with no region is returned unchanged apart from `trimEnd()`. + */ +function stripRegion(body) { + const text = String(body ?? ""); + if (!hasRegion(text)) { + return text.trimEnd(); + } + return text + .replaceAll(REGION_SEAM_PATTERN, (match, offset, full) => { + const atStart = offset === 0; + const atEnd = offset + match.length === full.length; + return atStart || atEnd ? "" : "\n\n"; + }) + .trimEnd(); +} + +/** + * Upsert the region for `version`: remove whatever region is present and append + * the fresh one at the END of the body. + * + * Remove-then-append rather than replace-in-place, so the region is at the end + * no matter where the previous one sat — a body a human has reordered, or one + * whose region was manually deleted, converges to the same string. Idempotent: + * running it twice with the same version returns the same body. + */ +function upsertRegion(body, version) { + const region = renderRegion(version); + const description = stripRegion(body); + return description.length === 0 ? region : `${description}\n\n${region}`; +} + +/** + * Pick the open "Version Packages" pull request out of what the pulls query + * returned, or `undefined` when there is none. + * + * `undefined` is a NORMAL answer, not an error: the branch exists only while + * changesets are pending. + * + * BOTH REFS ARE MATCHED HERE, not just the head, and neither is trusted from + * the query string. GitHub allows several open pull requests from ONE head + * branch to different bases, so `head=:changeset-release/main` alone can + * return more than one — a second PR opened from that branch for testing, say — + * and a `.find()` on the head ref would then write this region onto whichever + * the API happened to list first. The one this workflow means is the one + * changesets opens, which targets the default branch. The workflow's query + * filters on both as well; this is the check that holds if that query is ever + * widened or mistyped. + */ +function selectVersionPullRequest( + pulls, + branch = VERSION_PR_BRANCH, + base = DEFAULT_BRANCH +) { + if (!Array.isArray(pulls)) { + throw new Error( + "the pulls response is not an array — expected the body of GET /repos/{owner}/{repo}/pulls" + ); + } + return pulls.find( + (pull) => + pull && + pull.head && + pull.head.ref === branch && + pull.base && + pull.base.ref === base && + (pull.state === undefined || pull.state === "open") + ); +} + +/** + * The version named by the region already on `body`, or `undefined` when there + * is none (or when the region has been edited past recognition). + */ +function readRegionVersion(body) { + const region = REGION_PATTERN.exec(String(body ?? "")); + if (!region) { + return undefined; + } + const match = INSTALL_LINE_PATTERN.exec(region[0]); + return match ? match[1] : undefined; +} + +/** + * Is `candidate` a LATER build than `existing`? Used to keep this write + * monotonic. + * + * ORDERING ACROSS RUNS IS NOT GUARANTEED BY `needs:`. That only orders jobs + * within one run, and this workflow deliberately has no concurrency group (see + * the file header — gate 2 is per-SHA, so two runs cannot both publish). Two + * pushes to `main` in quick succession therefore start two independent runs, + * and nothing stops the OLDER run's breadcrumb job from reaching the PATCH + * after the newer one did. Without this check that older job would leave the + * pull request advertising a build that has already been superseded — an + * install line pointing at yesterday's nightly, with every run green. + * + * A concurrency group would be the wrong instrument: it would also cancel + * in-progress PUBLISHES, trading a cosmetic staleness for a lost package. The + * comparison is on the 14-digit UTC stamp, which is fixed-width, so a lexical + * compare orders builds chronologically for any base version (design D3 chose + * that layout for exactly this). + */ +function isNewerBuild(candidate, existing) { + if (existing === undefined) { + return true; + } + let existingStamp; + try { + existingStamp = parseStampedVersion(existing).stamp; + } catch { + // The region was edited into something unrecognizable. Treat it as absent + // and rewrite it: a body carrying a broken region should converge. + return true; + } + return parseStampedVersion(candidate).stamp > existingStamp; +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.length === 0 || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--version") { + index += 1; + options.version = requireValue(argv, index, "--version"); + } else if (argument === "--pulls") { + index += 1; + options.pulls = resolve(requireValue(argv, index, "--pulls")); + } else if (argument === "--out") { + index += 1; + options.out = resolve(requireValue(argv, index, "--out")); + } else { + throw new Error(`unknown argument: ${argument}`); + } + } + for (const flag of ["version", "pulls", "out"]) { + if (!options[flag]) { + throw new Error(`--${flag} is required`); + } + } + return options; +} + +function setOutput(key, value) { + const file = process.env.GITHUB_OUTPUT; + if (file) { + appendFileSync(file, `${key}=${value}\n`); + } +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + const pulls = JSON.parse(readFileSync(options.pulls, "utf8")); + const pull = selectVersionPullRequest(pulls); + + if (!pull) { + // Not a failure. See the header: no open Version Packages pull request is + // an ordinary state, and the nightly has already published. + console.log( + `No open ${VERSION_PR_BRANCH} pull request — nothing to annotate.` + ); + setOutput("changed", "false"); + return; + } + + setOutput("pull_number", pull.number); + + // Monotonic: an older run that reaches this point after a newer one wrote + // must not roll the pull request back to the build it published. See + // isNewerBuild — job ordering across runs is not guaranteed and a concurrency + // group would cancel publishes to fix a cosmetic race. + const present = readRegionVersion(pull.body ?? ""); + if (!isNewerBuild(options.version, present)) { + console.log( + `#${pull.number} already names ${present}, which is not older than ${options.version} — leaving it alone.` + ); + setOutput("changed", "false"); + return; + } + + const body = upsertRegion(pull.body ?? "", options.version); + if (body === (pull.body ?? "")) { + console.log(`#${pull.number} already carries this build's region.`); + setOutput("changed", "false"); + return; + } + + writeFileSync(options.out, body); + console.log(`#${pull.number}: build info for ${options.version}`); + setOutput("changed", "true"); +} + +module.exports = { + DEFAULT_BRANCH, + NIGHTLY_PACKAGE, + REGION_CLOSE, + REGION_OPEN, + VERSION_PR_BRANCH, + hasRegion, + isNewerBuild, + parseArguments, + readRegionVersion, + parseStampedVersion, + renderRegion, + selectVersionPullRequest, + stripRegion, + upsertRegion, +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`\nnightly-breadcrumb failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/nightly-breadcrumb.test.cjs b/.github/scripts/nightly-breadcrumb.test.cjs new file mode 100644 index 0000000..09a49ea --- /dev/null +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + NIGHTLY_PACKAGE, + VERSION_PR_BRANCH, + hasRegion, + isNewerBuild, + parseArguments, + readRegionVersion, + parseStampedVersion, + renderRegion, + selectVersionPullRequest, + stripRegion, + upsertRegion, +} = require("./nightly-breadcrumb.cjs"); + +const VERSION = "0.11.0-20260818123456x05b3c88"; +const NEXT_VERSION = "0.11.0-20260819080102xabc1234"; + +/** A stack-breadcrumb region, as stack-breadcrumb.yml writes it. */ +const STACK_REGION = [ + "", + "**Stack** (root → tip):", + "", + "- #71", + " - ➡️ #93 (you are here)", + "", +].join("\n"); + +const DESCRIPTION = + "# Releases\n\n## @taskless/cli@0.11.0\n\n### Minor Changes"; + +test("parseStampedVersion reads the build time and sha back out of the version", () => { + assert.deepEqual(parseStampedVersion(VERSION), { + builtAt: "2026-08-18 12:34:56", + shortSha: "05b3c88", + // The raw stamp is kept so builds can be ordered without re-parsing. + stamp: "20260818123456", + }); +}); + +// Nothing here may consult a clock: the version is stamped once per run and +// every fact in the region has to agree with it. +test("parseStampedVersion refuses a version that is not stamped", () => { + for (const version of ["0.11.0", "0.11.0-beta.1", "", undefined]) { + assert.throws( + () => parseStampedVersion(version), + /not a stamped nightly version/ + ); + } +}); + +test("renderRegion names the package that is actually published", () => { + const region = renderRegion(VERSION); + assert.equal( + region, + [ + "", + "### Build Info", + "`npx @taskless/cli-nightly@0.11.0-20260818123456x05b3c88`", + "", + "**Built from:** 05b3c88", + "**Built at:** 2026-08-18 12:34:56", + "", + ].join("\n") + ); + // The nightly is published as @taskless/cli-nightly; an install line naming + // @taskless/cli would send a reviewer to the last RELEASE instead of to this + // build. + assert.match(region, /npx @taskless\/cli-nightly@/); + assert.equal(NIGHTLY_PACKAGE, "@taskless/cli-nightly"); +}); + +test("upsertRegion appends at the end when no region is present", () => { + const body = upsertRegion(DESCRIPTION, VERSION); + assert.ok(body.startsWith(DESCRIPTION)); + assert.ok(body.endsWith("")); + assert.equal(body, `${DESCRIPTION}\n\n${renderRegion(VERSION)}`); +}); + +test("upsertRegion writes into an empty body without leading blank lines", () => { + assert.equal(upsertRegion("", VERSION), renderRegion(VERSION)); + assert.equal(upsertRegion(undefined, VERSION), renderRegion(VERSION)); +}); + +// The failure this exists to prevent: a nightly publishes every push, so a +// region that were appended rather than replaced would grow one block per day. +test("repeated publishes replace the region, never accumulate", () => { + let body = upsertRegion(DESCRIPTION, VERSION); + body = upsertRegion(body, NEXT_VERSION); + body = upsertRegion(body, NEXT_VERSION); + assert.equal(body.match(//g).length, 1); + assert.equal(body.match(//g).length, 1); + assert.equal(body, `${DESCRIPTION}\n\n${renderRegion(NEXT_VERSION)}`); + assert.ok(!body.includes(VERSION)); +}); + +test("upsertRegion is idempotent for one version", () => { + const once = upsertRegion(DESCRIPTION, VERSION); + assert.equal(upsertRegion(once, VERSION), once); + assert.equal(upsertRegion(upsertRegion(once, VERSION), VERSION), once); +}); + +test("a manually deleted region is re-attached at the end", () => { + const withRegion = upsertRegion(DESCRIPTION, VERSION); + // What a human does: select the block, delete it, save. + const deleted = withRegion.replace(renderRegion(VERSION), "").trimEnd(); + assert.ok(!hasRegion(deleted)); + assert.equal(upsertRegion(deleted, VERSION), withRegion); +}); + +// stack-breadcrumb.yml maintains its own region on the same bodies. Neither +// pattern may match the other's markers, and the region must land after +// whatever else is there — always at the end. +test("the stack-breadcrumb region is left byte-for-byte alone", () => { + const body = `${STACK_REGION}\n\n${DESCRIPTION}`; + const annotated = upsertRegion(body, VERSION); + + assert.ok(annotated.includes(STACK_REGION)); + assert.equal( + annotated.match(//g).length, + 1 + ); + assert.equal(annotated.match(//g).length, 1); + assert.equal(annotated, `${body}\n\n${renderRegion(VERSION)}`); + + // And a second publish still only touches the nightly region. + const republished = upsertRegion(annotated, NEXT_VERSION); + assert.equal(republished, `${body}\n\n${renderRegion(NEXT_VERSION)}`); +}); + +test("a stack region containing the word nightly is not mistaken for one", () => { + const body = [ + "", + "**Stack** (root → tip):", + "", + "- #71 nightly breadcrumbs", + "", + ].join("\n"); + assert.ok(!hasRegion(body)); + assert.equal(stripRegion(body), body); +}); + +// If a body's region is moved into the middle (a human editing around it), the +// next publish must not leave it there — "always at the end" is the contract. +test("a region sitting mid-body is moved to the end, not duplicated", () => { + const body = `${renderRegion(VERSION)}\n\n${DESCRIPTION}`; + const annotated = upsertRegion(body, NEXT_VERSION); + assert.equal(annotated, `${DESCRIPTION}\n\n${renderRegion(NEXT_VERSION)}`); + assert.equal(annotated.match(//g).length, 1); +}); + +// Copilot review on #133: normalization must be scoped to the seam the removal +// leaves, not applied to the whole body. Blank lines the author put somewhere +// else are content, and a republish must not rewrite them. +test("blank lines elsewhere in the description survive a republish", () => { + const authored = [ + "# Releases", + "", + "", + "", + "Deliberate breathing room above this line.", + "", + "", + "And below it.", + ].join("\n"); + + const once = upsertRegion(authored, VERSION); + assert.equal(once, `${authored}\n\n${renderRegion(VERSION)}`); + + // The republish is the dangerous one: it strips the region it wrote last + // time, which is when a body-wide collapse would fire. + const twice = upsertRegion(once, NEXT_VERSION); + assert.equal(twice, `${authored}\n\n${renderRegion(NEXT_VERSION)}`); + assert.ok(twice.startsWith(authored)); + assert.equal(stripRegion(twice), authored); +}); + +test("the seam left by a mid-body region becomes exactly one blank line", () => { + const body = `above\n\n${renderRegion(VERSION)}\n\nbelow`; + assert.equal(stripRegion(body), "above\n\nbelow"); +}); + +test("stripRegion returns a region-free body byte-for-byte", () => { + // Deliberately whitespace-heavy: a run that has nothing to remove must not + // reflow prose, and an indented code block must survive. + const body = " const x = 1;\n\n\n\nstill mine "; + assert.equal(stripRegion(body), body.trimEnd()); +}); + +/** The shape the pulls endpoint returns, trimmed to what the selector reads. */ +const pull = (number, ref, base = "main") => ({ + number, + state: "open", + head: { ref }, + base: { ref: base }, + body: "", +}); + +test("selectVersionPullRequest finds the open Version Packages pull request", () => { + const pulls = [pull(12, "feat/other"), pull(74, VERSION_PR_BRANCH)]; + assert.equal(selectVersionPullRequest(pulls).number, 74); +}); + +// claude[bot] review on #133: GitHub allows several open PRs from one head +// branch to different bases, so matching the head alone can annotate the wrong +// one — whichever the API happened to list first. +test("a same-head pull request targeting another base is not selected", () => { + const decoy = pull(90, VERSION_PR_BRANCH, "some/test-base"); + const real = pull(74, VERSION_PR_BRANCH); + + // Listed FIRST, so a head-only `.find()` would return it. + assert.equal(selectVersionPullRequest([decoy, real]).number, 74); + assert.equal(selectVersionPullRequest([decoy]), undefined); +}); + +test("a pull request with no base is not selected", () => { + assert.equal( + selectVersionPullRequest([ + { number: 74, state: "open", head: { ref: VERSION_PR_BRANCH } }, + ]), + undefined + ); +}); + +test("readRegionVersion reads back the version a publish wrote", () => { + assert.equal(readRegionVersion(upsertRegion(DESCRIPTION, VERSION)), VERSION); + assert.equal(readRegionVersion(DESCRIPTION), undefined); + assert.equal(readRegionVersion(""), undefined); +}); + +// The cross-run race: `needs:` orders jobs inside ONE run, and this workflow +// has no concurrency group on purpose, so an older run's breadcrumb job can +// reach the write after a newer one did. +test("isNewerBuild keeps the write monotonic", () => { + assert.equal(isNewerBuild(NEXT_VERSION, VERSION), true); + assert.equal(isNewerBuild(VERSION, NEXT_VERSION), false); + assert.equal(isNewerBuild(VERSION, VERSION), false); + assert.equal(isNewerBuild(VERSION, undefined), true); + // A base-version bump does not reorder builds: the stamp decides. + assert.equal(isNewerBuild("0.12.0-20260817000000xaaaaaaa", VERSION), false); + // A region edited past recognition is treated as absent and rewritten. + assert.equal(isNewerBuild(VERSION, "hand-edited"), true); +}); + +// The whole point of the "no open pull request" branch: it is a normal state, +// and a cosmetic breadcrumb must never fail a run that already published. +test("no Version Packages pull request is undefined, not an error", () => { + assert.equal(selectVersionPullRequest([]), undefined); + assert.equal( + selectVersionPullRequest([ + { number: 12, state: "open", head: { ref: "feat/other" } }, + ]), + undefined + ); +}); + +// A malformed response is NOT the same as an empty one — collapsing the two is +// exactly the fail-open this file must not have. +test("a pulls response that is not an array throws", () => { + assert.throws( + () => selectVersionPullRequest({ message: "Not Found" }), + /not an array/ + ); + assert.throws(() => selectVersionPullRequest(undefined), /not an array/); +}); + +test("parseArguments requires all three flags", () => { + assert.deepEqual( + parseArguments([ + "--version", + VERSION, + "--pulls", + "/tmp/pulls.json", + "--out", + "/tmp/body.md", + ]), + { version: VERSION, pulls: "/tmp/pulls.json", out: "/tmp/body.md" } + ); + assert.throws( + () => parseArguments(["--version", VERSION]), + /--pulls is required/ + ); + assert.throws(() => parseArguments(["--nope"]), /unknown argument/); + assert.throws(() => parseArguments(["--version"]), /--version requires/); +}); diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index b950b14..36a32e1 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -163,6 +163,48 @@ # only version input pack mode accepts (it rejects `--status`/`--sha`, so it # cannot recompute one). # +# ANNOUNCING THE NIGHTLY ON THE VERSION PACKAGES PR. A nightly exists so the +# work sitting in pending changesets can be RUN before it is released, and the +# pull request where that audience already is, is the changesets "Version +# Packages" PR — the one that lists exactly those changesets. So after a +# publish, a third job writes a `` … `` region +# at the END of that body. The region is REMOVED AND RE-APPENDED on every +# publish rather than edited in place: deleting it is a supported thing for a +# human to do, and a body someone has reordered then converges instead of +# accumulating one block per day. +# +# The reasoning that belongs with the reader of this file, in the order it is +# likely to be questioned: +# +# WHY A THIRD JOB. Writing a PR body needs `pull-requests: write`. Granting +# that to `publish` would widen what a compromised step there can reach from +# "publish a package under the @taskless scope" to "publish a package AND +# rewrite pull request text" — including the text of the PR that gates the +# next release. It is the same boundary the gate/publish split already draws, +# drawn once more. The two capabilities never coexist in one job. +# +# WHY IT PARSES THE VERSION INSTEAD OF MEASURING ANYTHING. The stamp is +# `-x`, so it already carries the build time and +# the commit. A fresh `Date.now()` would print a time disagreeing with the +# version on the line above it, and a fresh `git rev-parse` a sha the +# published tarball does not carry. This is the THIRD consumer of the +# stamped-once rule (build, pack, breadcrumb) and it obeys it by reading the +# version backwards — see parseStampedVersion in nightly-breadcrumb.cjs. +# +# WHY "NO PULL REQUEST" IS SUCCESS AND "NO ANSWER" IS NOT. See the job's own +# comment below; the distinction is the same fail-open closed in gate 2. +# +# WHY NOT stack-breadcrumb.cjs. Its REGION_PATTERN hardcodes the name `stack` +# and its upsert is keyed to PR numbers; neither generalizes to a singleton +# region under another name. The two regions do coexist on one body — a +# Version Packages PR can be in a stack — and neither pattern can match the +# other's markers, which nightly-breadcrumb.test.cjs covers directly. +# +# WHY THE REGION NAMES @taskless/cli-nightly. Issue #128 wrote the install +# line as `npx @taskless/cli@`. That version exists only under the +# nightly name (D2), so the literal line would 404 — or, worse, resolve to +# the last RELEASE and look like it worked. +# # BOOTSTRAPPING THE PACKAGE NAME, once — the same three inputs in the same # order, including the build, which produces the `dist/` the tarball carries: # @@ -364,6 +406,12 @@ jobs: needs: gate if: needs.gate.outputs.should_publish == 'true' runs-on: ubuntu-latest + # The stamped version, for the breadcrumb job below. Published exactly once + # (see "ONE VERSION, TWO CONSUMERS" above) and passed on, never recomputed — + # a second stamp would read a second clock and name a version that was + # never published. + outputs: + version: ${{ steps.version.outputs.version }} # The scoping and audit boundary for the nightly, and where the npm trusted # publisher for @taskless/cli-nightly is bound. No required reviewer, by # design; its deployment branch policy restricts it to main. @@ -509,3 +557,105 @@ jobs: else npm publish --provenance --access public --tag latest "$NIGHTLY_TARBALL" fi + + # A THIRD JOB, AND IT HOLDS NO CREDENTIAL. Writing a pull request body needs + # `pull-requests: write`, and adding that to `publish` would widen what a + # compromised step there can reach from "publish a package under the + # @taskless scope" to "publish a package AND rewrite pull request text" — + # including the text of the pull request that gates the next release. So the + # split already drawn between `gate` and `publish` is drawn once more: this + # job has `pull-requests: write` and NOTHING ELSE — no `id-token`, no write + # access to contents, no environment, no npm identity. The two capabilities + # never coexist in one job. + # + # `needs: publish` is the "block on a successful publish" requirement: a job + # whose dependency was skipped does not run, so a suppressed nightly (either + # gate false) never reaches this, and neither does a failed publish. + # + # NO OPEN VERSION PACKAGES PULL REQUEST IS NORMAL AND MUST SUCCEED QUIETLY. + # `changeset-release/main` exists only while changesets are pending, and a + # nightly can publish in the seconds before changesets opens it. A cosmetic + # breadcrumb must never fail a run that already published to npm. That is NOT + # the same as the query failing — `gh api` exiting non-zero fails the step, + # because "there is no pull request" and "I could not find out" are different + # answers and only one of them is fine. + breadcrumb: + name: Link the nightly on the Version Packages PR + needs: publish + runs-on: ubuntu-latest + permissions: + contents: read # checkout only — the script lives in this repo + pull-requests: write # the one capability this job exists to use + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false # gh authenticates with GITHUB_TOKEN below + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + # A LOST UPDATE IS POSSIBLE HERE, AND IS ACCEPTED (Copilot review, #133). + # A pull request body is replaced whole: GitHub offers no field-level + # patch and no compare-and-swap — PATCH on a pull request honors no + # `If-Match` — so every writer is doing read-modify-write against a + # resource two others also touch. changesets regenerates this body on each + # push to its branch, and stack-breadcrumb.yml rewrites it when the pull + # request is stacked. A write landing between the GET below and the PATCH + # is therefore lost, and no arrangement of these API calls prevents it. + # + # It is accepted because all three writers are ADDITIVE AND SELF-HEALING, + # so a lost update costs one cycle rather than data: if changesets wins, + # the next nightly re-appends its region; if this job wins, changesets + # rewrites the release notes on the next push; the stack breadcrumb + # reconciles on its own dispatch. Coordinating them — a lock, or routing + # all three through one workflow — would buy consistency for a cosmetic + # region by coupling a release workflow to a breadcrumb, which is the + # worse trade. Reach for that only if a writer appears whose content + # cannot be reconstructed. + # + # No dependency install: nightly-breadcrumb.cjs is zero-dependency + # CommonJS, for the same reason the gate job's matcher is. + - id: breadcrumb + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + # The version the publish job stamped. Routed through `env:` rather + # than interpolated into the shell body, as everywhere else here. + NIGHTLY_VERSION: ${{ needs.publish.outputs.version }} + run: | + set -euo pipefail + + # A plain query, allowed to fail. No `|| echo '[]'`: a fallback here + # would turn an API error into "no pull request found" and the run + # would go green having silently skipped the update. + # + # `base=main` is not redundant with `head=`. GitHub allows several + # open pull requests from ONE head branch to different bases, so the + # head filter alone can return more than one and the script would + # annotate whichever was listed first. The script re-checks both refs + # for the same reason, so widening this query cannot silently widen + # what gets written. + gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main&base=main" > nightly-pulls.json + + # Writes nightly-body.md only when the body actually changes, and + # exits 0 with changed=false when there is no open Version Packages + # pull request at all. + node .github/scripts/nightly-breadcrumb.cjs \ + --version "$NIGHTLY_VERSION" \ + --pulls nightly-pulls.json \ + --out nightly-body.md + + # `gh api -X PATCH`, never `gh pr edit`: the GraphQL path `gh pr edit` + # takes is broken by GitHub's Projects (classic) deprecation (see + # CLAUDE.md, "Editing an existing PR"). + - if: steps.breadcrumb.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PULL_NUMBER: ${{ steps.breadcrumb.outputs.pull_number }} + run: | + set -euo pipefail + gh api -X PATCH "repos/${REPO}/pulls/${PULL_NUMBER}" \ + -f body="$(cat nightly-body.md)" > /dev/null + echo "Updated #${PULL_NUMBER} with the nightly build info." diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index faf6877..34990f6 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -190,3 +190,50 @@ Nightly publishing SHALL authenticate with a short-lived credential minted for t - **WHEN** a nightly is published - **THEN** the published version SHALL carry a build-provenance attestation - **AND** no long-lived registry token SHALL be present in the environment + +### Requirement: A published nightly is announced on the pending release pull request + +When a nightly is published, the open pull request that carries the pending release metadata SHALL be annotated with a delimited build-info region naming the published package, the version, the commit it was built from, and the time it was built — so the reviewers of that pull request can install and exercise the work it describes. + +Every fact in that region SHALL be derived from the version the publish stamped, not determined independently. The version already encodes the build time and the commit, and a second determination reads a second clock. + +Each publish SHALL append the region at the end of the body it writes, SHALL replace any region a previous publish left rather than adding to it, and SHALL be restored if a human deletes it. It SHALL NOT modify any other managed region on that body. + +Placement is asserted of the write, not of the body for all time: other writers maintain their own regions on the same body and may re-lay it, moving this region out of last place. A publish SHALL return the region to the end rather than leave it where it was found. A publish SHALL NOT overwrite a region naming a build newer than its own. + +The annotation SHALL depend on the publish having succeeded, and SHALL be performed by a job that holds permission to write pull requests and holds no publishing credential — the ability to publish under the organization's scope and the ability to rewrite pull request text SHALL NOT be held by one job. + +#### Scenario: A publish annotates the open release pull request + +- **WHEN** a nightly is published and a pull request carrying the pending release metadata is open +- **THEN** that pull request's body SHALL end with a build-info region naming the published package, version, commit, and build time + +#### Scenario: Repeated publishes replace the region + +- **WHEN** a second nightly is published while the same pull request is open +- **THEN** the pull request SHALL carry exactly one build-info region, describing the most recent publish + +#### Scenario: Another writer moves the region + +- **WHEN** another writer re-lays the body and the region no longer sits at the end +- **THEN** the next publish SHALL move it back to the end rather than leave it in place or write a second one + +#### Scenario: An older build does not overwrite a newer one + +- **WHEN** the region on the pull request names a build newer than the one being announced +- **THEN** the body SHALL be left unchanged + +#### Scenario: No open release pull request is not a failure + +- **WHEN** a nightly is published and no pull request carrying pending release metadata is open +- **THEN** the run SHALL succeed and annotate nothing + +#### Scenario: An unanswered query is a failure + +- **WHEN** the query for the pull request fails +- **THEN** the run SHALL fail rather than treat the failure as "no pull request is open" + +#### Scenario: A suppressed nightly annotates nothing + +- **WHEN** a push publishes no nightly +- **THEN** no job holding permission to write pull requests SHALL be instantiated for it