From 1a95bf731c58b4b82b3c19d4a93696ae1e39a26e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 20 Aug 2026 21:11:42 -0700 Subject: [PATCH 1/3] feat(ci): announce a published nightly on the Version Packages PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a nightly publishes, the open `changeset-release/main` pull request gains a `` region at the end of its body naming the build: the install line, the commit it was built from, and the time it was built. Reviewers of that PR are exactly the audience for a build of the work its changesets describe. The region is removed and re-appended on every publish, so a hand-deleted region comes back, repeated publishes replace rather than accumulate, and a body someone has reordered converges. It never touches the `` region stack-breadcrumb.yml may maintain on the same body. A THIRD JOB, holding `pull-requests: write` and no credential — never a step in `publish`, which would then hold both an npm OIDC identity and the ability to rewrite pull request text. It `needs: publish`, so a suppressed nightly never reaches it, and the version arrives as a job output: the stamp already encodes the build time and the sha, so both are parsed back out rather than read from a second clock. No open Version Packages PR exits 0 — the branch exists only while changesets are pending — but a failed `gh api` query still fails the step. Note: the issue writes the install line as `npx @taskless/cli@`. Nightlies publish as `@taskless/cli-nightly`, and that version does not exist under the released name, so the region names the package that will actually install. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .github/scripts/nightly-breadcrumb.cjs | 299 ++++++++++++++++++++ .github/scripts/nightly-breadcrumb.test.cjs | 208 ++++++++++++++ .github/workflows/release-cli-nightly.yml | 121 ++++++++ openspec/specs/cli-nightly-builds/spec.md | 35 +++ 4 files changed, 663 insertions(+) create mode 100644 .github/scripts/nightly-breadcrumb.cjs create mode 100644 .github/scripts/nightly-breadcrumb.test.cjs diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs new file mode 100644 index 0000000..d1017f7 --- /dev/null +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -0,0 +1,299 @@ +#!/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. + * + * 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"; + +const REGION_OPEN = ""; +const REGION_CLOSE = ""; + +/** + * 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]*?/; + +/** + * The same pattern, global — 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. + */ +const ALL_REGIONS_PATTERN = new RegExp(REGION_PATTERN.source, "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}`); + } + return { builtAt, shortSha: sha }; +} + +/** 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`, tidying only the whitespace the + * removal itself left behind (a 3+ newline seam collapses to a blank line, + * trailing whitespace goes). When there was no region the body is returned + * BYTE-FOR-BYTE — a description that legitimately contains a run of blank + * lines, or an indented code block, is never reflowed by a run that had nothing + * to remove. + */ +function stripRegion(body) { + const text = String(body ?? ""); + if (!hasRegion(text)) { + return text.trimEnd(); + } + return ( + text + .replaceAll(ALL_REGIONS_PATTERN, "") + .replaceAll(/\n{3,}/g, "\n\n") + // Leading blank lines only ever appear here when the region sat at the very + // top of the body; the first line's own indentation is untouched, so a + // description opening with an indented code block is not reflowed. + .replace(/^\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. The head ref is still matched here rather than + * trusted from the query string, so a widened or mistyped filter cannot lead to + * this writing its region onto an unrelated pull request. + */ +function selectVersionPullRequest(pulls, branch = VERSION_PR_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.state === undefined || pull.state === "open") + ); +} + +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; + } + + const body = upsertRegion(pull.body ?? "", options.version); + setOutput("pull_number", pull.number); + 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 = { + NIGHTLY_PACKAGE, + REGION_CLOSE, + REGION_OPEN, + VERSION_PR_BRANCH, + hasRegion, + parseArguments, + 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..80d5b89 --- /dev/null +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + NIGHTLY_PACKAGE, + VERSION_PR_BRANCH, + hasRegion, + parseArguments, + 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", + }); +}); + +// 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); +}); + +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()); +}); + +test("selectVersionPullRequest finds the open Version Packages pull request", () => { + const pulls = [ + { number: 12, state: "open", head: { ref: "feat/other" }, body: "" }, + { number: 74, state: "open", head: { ref: VERSION_PR_BRANCH }, body: "hi" }, + ]; + assert.equal(selectVersionPullRequest(pulls).number, 74); +}); + +// 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..8fd8a94 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,76 @@ 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 + + # 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. + gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main" > nightly-pulls.json + + 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..66741be 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -190,3 +190,38 @@ 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. + +The region SHALL be placed at the end of the pull request body, 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. + +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: 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 From 53ca4590b33e85c9eb4d11121c50f2b2c033605b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 21 Aug 2026 01:19:10 -0700 Subject: [PATCH 2/3] fix(ci): scope the breadcrumb's whitespace tidy to the seam it creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #133. `stripRegion` collapsed every 3+ newline run in the body, so an intentional blank-line run anywhere in the description was rewritten on each republish — content this file does not own. The removal pattern now eats the newlines around the region and the replacer decides what belongs there: a blank line between two pieces of prose, nothing at the top or the bottom of the body. Prose elsewhere is untouched. Two regression tests cover it, including the republish, which is the path where the collapse used to fire. Also documents the lost-update window the same review raised: a PR body is replaced whole and GitHub offers no compare-and-swap, so the nightly region, the changesets release notes, and the stack breadcrumb can each overwrite one another. Accepted rather than coordinated — all three writers are additive and self-healing, so a lost write costs one cycle, and a lock would couple a release workflow to a cosmetic breadcrumb. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .github/scripts/nightly-breadcrumb.cjs | 49 ++++++++++++--------- .github/scripts/nightly-breadcrumb.test.cjs | 31 +++++++++++++ .github/workflows/release-cli-nightly.yml | 22 +++++++++ 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs index d1017f7..e599bda 100644 --- a/.github/scripts/nightly-breadcrumb.cjs +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -81,11 +81,17 @@ const REGION_CLOSE = ""; const REGION_PATTERN = /[\S\s]*?/; /** - * The same pattern, global — 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. + * 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 ALL_REGIONS_PATTERN = new RegExp(REGION_PATTERN.source, "g"); +const REGION_SEAM_PATTERN = new RegExp(`\\n*${REGION_PATTERN.source}\\n*`, "g"); /** * The stamped prerelease identifier, anchored to the end: `-<14 digits>x`. @@ -144,28 +150,31 @@ function hasRegion(body) { } /** - * Strip every nightly region from `body`, tidying only the whitespace the - * removal itself left behind (a 3+ newline seam collapses to a blank line, - * trailing whitespace goes). When there was no region the body is returned - * BYTE-FOR-BYTE — a description that legitimately contains a run of blank - * lines, or an indented code block, is never reflowed by a run that had nothing - * to remove. + * 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(ALL_REGIONS_PATTERN, "") - .replaceAll(/\n{3,}/g, "\n\n") - // Leading blank lines only ever appear here when the region sat at the very - // top of the body; the first line's own indentation is untouched, so a - // description opening with an indented code block is not reflowed. - .replace(/^\n+/, "") - .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(); } /** diff --git a/.github/scripts/nightly-breadcrumb.test.cjs b/.github/scripts/nightly-breadcrumb.test.cjs index 80d5b89..34c1bf3 100644 --- a/.github/scripts/nightly-breadcrumb.test.cjs +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -150,6 +150,37 @@ test("a region sitting mid-body is moved to the end, not duplicated", () => { 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. diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index 8fd8a94..e10d3af 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -594,6 +594,25 @@ jobs: 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 @@ -612,6 +631,9 @@ jobs: # would go green having silently skipped the update. gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/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 \ From b7b3b323c716a7d2dffcc212f435ce927370ca8e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 21 Aug 2026 09:16:38 -0700 Subject: [PATCH 3/3] fix(ci): match the version PR by base too, and keep the write monotonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the claude review on #133. **The pull request was selected on its head ref alone.** GitHub allows several open pull requests from one head branch to different bases, so `head=:changeset-release/main` can return more than one and `.find()` would annotate whichever the API listed first. Both refs are now matched in the script, and the query carries `base=main` as well — the script re-checks so that widening the query cannot silently widen what gets written. **An older run could roll the region back.** `needs:` orders jobs inside one run, and this workflow has no concurrency group on purpose, so two pushes in quick succession can leave the older run's breadcrumb job writing last. The write is now monotonic: the version already on the pull request is read back out of the install line and compared on the 14-digit UTC stamp, which is fixed-width precisely so a lexical compare orders builds. A concurrency group would have been the wrong instrument — it would cancel in-progress publishes to fix a cosmetic race. **"At the end" was claimed unconditionally, and the stack breadcrumb can break it.** `stack-breadcrumb.cjs`'s `canonicalizeBody` re-lays a stacked body as breadcrumb → description → carried regions, and its `ownDescription` strips only the regions it owns — so this one rides inside "description" and lands above the carried blocks. Nothing here runs at that moment. The spec now asserts placement of the write and requires the next publish to return the region to the end, with a scenario for each; the script says where the real fix belongs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3 --- .github/scripts/nightly-breadcrumb.cjs | 118 +++++++++++++++++++- .github/scripts/nightly-breadcrumb.test.cjs | 59 +++++++++- .github/workflows/release-cli-nightly.yml | 9 +- openspec/specs/cli-nightly-builds/spec.md | 14 ++- 4 files changed, 188 insertions(+), 12 deletions(-) diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs index e599bda..d6bbee4 100644 --- a/.github/scripts/nightly-breadcrumb.cjs +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -39,6 +39,19 @@ * 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 @@ -70,9 +83,22 @@ 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 @@ -127,7 +153,9 @@ function parseStampedVersion(version) { if (Number.isNaN(parsed.getTime())) { throw new Error(`stamped version carries an impossible time: ${version}`); } - return { builtAt, shortSha: sha }; + // `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. */ @@ -197,11 +225,23 @@ function upsertRegion(body, version) { * returned, or `undefined` when there is none. * * `undefined` is a NORMAL answer, not an error: the branch exists only while - * changesets are pending. The head ref is still matched here rather than - * trusted from the query string, so a widened or mistyped filter cannot lead to - * this writing its region onto an unrelated pull request. + * 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) { +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" @@ -212,10 +252,59 @@ function selectVersionPullRequest(pulls, branch = VERSION_PR_BRANCH) { 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("--")) { @@ -271,8 +360,22 @@ function main() { return; } - const body = upsertRegion(pull.body ?? "", options.version); 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"); @@ -285,12 +388,15 @@ function main() { } module.exports = { + DEFAULT_BRANCH, NIGHTLY_PACKAGE, REGION_CLOSE, REGION_OPEN, VERSION_PR_BRANCH, hasRegion, + isNewerBuild, parseArguments, + readRegionVersion, parseStampedVersion, renderRegion, selectVersionPullRequest, diff --git a/.github/scripts/nightly-breadcrumb.test.cjs b/.github/scripts/nightly-breadcrumb.test.cjs index 34c1bf3..09a49ea 100644 --- a/.github/scripts/nightly-breadcrumb.test.cjs +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -8,7 +8,9 @@ const { NIGHTLY_PACKAGE, VERSION_PR_BRANCH, hasRegion, + isNewerBuild, parseArguments, + readRegionVersion, parseStampedVersion, renderRegion, selectVersionPullRequest, @@ -36,6 +38,8 @@ 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", }); }); @@ -188,14 +192,61 @@ test("stripRegion returns a region-free body byte-for-byte", () => { 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 = [ - { number: 12, state: "open", head: { ref: "feat/other" }, body: "" }, - { number: 74, state: "open", head: { ref: VERSION_PR_BRANCH }, body: "hi" }, - ]; + 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", () => { diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index e10d3af..36a32e1 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -629,7 +629,14 @@ jobs: # 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. - gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main" > nightly-pulls.json + # + # `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 diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index 66741be..34990f6 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -197,7 +197,9 @@ When a nightly is published, the open pull request that carries the pending rele 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. -The region SHALL be placed at the end of the pull request body, 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. +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. @@ -211,6 +213,16 @@ The annotation SHALL depend on the publish having succeeded, and SHALL be perfor - **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