From 3e9697d2dfe12e7ab190eb96a1969f128f3897a8 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 6 Oct 2026 11:28:12 -0700 Subject: [PATCH] fix(ci): put the nightly build info at the top of the Version Packages PR --- .github/scripts/nightly-breadcrumb.cjs | 64 ++++++++++------- .github/scripts/nightly-breadcrumb.test.cjs | 69 ++++++++++++++----- .github/workflows/release-cli-nightly.yml | 5 +- .../proposal.md | 28 ++++++++ .../specs/cli-nightly-builds/spec.md | 53 ++++++++++++++ .../2026-10-06-nightly-region-at-top/tasks.md | 21 ++++++ openspec/specs/cli-nightly-builds/spec.md | 15 ++-- 7 files changed, 204 insertions(+), 51 deletions(-) create mode 100644 openspec/changes/archive/2026-10-06-nightly-region-at-top/proposal.md create mode 100644 openspec/changes/archive/2026-10-06-nightly-region-at-top/specs/cli-nightly-builds/spec.md create mode 100644 openspec/changes/archive/2026-10-06-nightly-region-at-top/tasks.md diff --git a/.github/scripts/nightly-breadcrumb.cjs b/.github/scripts/nightly-breadcrumb.cjs index d6bbee4e..2e0da668 100644 --- a/.github/scripts/nightly-breadcrumb.cjs +++ b/.github/scripts/nightly-breadcrumb.cjs @@ -27,30 +27,27 @@ * 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 + * THE REGION IS REMOVED AND RE-INSERTED, 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. + * TOP of the body — the first thing a reviewer sees, above the release notes + * changesets writes — 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 top" means the top of the DESCRIPTION. When the body opens with a + * `` … `` region, which stack-breadcrumb.yml may + * be maintaining on the same body, this region goes directly below it rather + * than above it. That is exactly where stack-breadcrumb.cjs's + * `canonicalizeBody` leaves it: that file re-lays the body as breadcrumb → + * description → carried `` regions, and its `ownDescription` + * strips only the regions IT owns, so this one travels at the head of + * "description" and lands right under the breadcrumb. Both writers therefore + * agree on one layout, and neither undoes the other on its next pass. Putting + * the region above the stack instead would have each writer move it on every + * run. * - * 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. + * The two regions are keyed on different names and neither pattern can match + * the other's markers; this file 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` — @@ -119,6 +116,15 @@ const REGION_PATTERN = /[\S\s]*?/; */ const REGION_SEAM_PATTERN = new RegExp(`\\n*${REGION_PATTERN.source}\\n*`, "g"); +/** + * A stack-breadcrumb region at the very start of a body, which is where + * stack-breadcrumb.cjs's `canonicalizeBody` always puts it. Mirrors that file's + * `REGION_PATTERN`, anchored. Only a LEADING one matters: it is the one this + * region goes below, so both writers agree on the layout. + */ +const LEADING_STACK_REGION_PATTERN = + /^[\S\s]*?/; + /** * The stamped prerelease identifier, anchored to the end: `-<14 digits>x`. * Same grammar nightly-pack.cjs writes (design D3), read in the other @@ -206,18 +212,24 @@ function stripRegion(body) { } /** - * Upsert the region for `version`: remove whatever region is present and append - * the fresh one at the END of the body. + * Upsert the region for `version`: remove whatever region is present and put + * the fresh one at the TOP of the description — the start of the body, or + * directly below a leading stack-breadcrumb region when there is one. * - * Remove-then-append rather than replace-in-place, so the region is at the end + * Remove-then-insert rather than replace-in-place, so the region is at the top * 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}`; + const stripped = stripRegion(body); + const stack = LEADING_STACK_REGION_PATTERN.exec(stripped); + const head = stack ? stack[0] : ""; + const description = stripped.slice(head.length).replace(/^\n+/, ""); + return [head, region, description] + .filter((section) => section.length > 0) + .join("\n\n"); } /** diff --git a/.github/scripts/nightly-breadcrumb.test.cjs b/.github/scripts/nightly-breadcrumb.test.cjs index 09a49eaf..f1957541 100644 --- a/.github/scripts/nightly-breadcrumb.test.cjs +++ b/.github/scripts/nightly-breadcrumb.test.cjs @@ -75,11 +75,11 @@ test("renderRegion names the package that is actually published", () => { assert.equal(NIGHTLY_PACKAGE, "@taskless/cli-nightly"); }); -test("upsertRegion appends at the end when no region is present", () => { +test("upsertRegion puts the region at the top when none is present", () => { const body = upsertRegion(DESCRIPTION, VERSION); - assert.ok(body.startsWith(DESCRIPTION)); - assert.ok(body.endsWith("")); - assert.equal(body, `${DESCRIPTION}\n\n${renderRegion(VERSION)}`); + assert.ok(body.startsWith("")); + assert.ok(body.endsWith(DESCRIPTION)); + assert.equal(body, `${renderRegion(VERSION)}\n\n${DESCRIPTION}`); }); test("upsertRegion writes into an empty body without leading blank lines", () => { @@ -95,7 +95,7 @@ test("repeated publishes replace the region, never accumulate", () => { 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.equal(body, `${renderRegion(NEXT_VERSION)}\n\n${DESCRIPTION}`); assert.ok(!body.includes(VERSION)); }); @@ -105,17 +105,18 @@ test("upsertRegion is idempotent for one version", () => { assert.equal(upsertRegion(upsertRegion(once, VERSION), VERSION), once); }); -test("a manually deleted region is re-attached at the end", () => { +test("a manually deleted region is re-attached at the top", () => { const withRegion = upsertRegion(DESCRIPTION, VERSION); // What a human does: select the block, delete it, save. - const deleted = withRegion.replace(renderRegion(VERSION), "").trimEnd(); + const deleted = withRegion.replace(renderRegion(VERSION), "").trimStart(); 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. +// pattern may match the other's markers, and the region must land directly +// below a leading stack region — where stack-breadcrumb.cjs's canonicalizeBody +// leaves it — so the two writers never move each other's work. test("the stack-breadcrumb region is left byte-for-byte alone", () => { const body = `${STACK_REGION}\n\n${DESCRIPTION}`; const annotated = upsertRegion(body, VERSION); @@ -126,11 +127,43 @@ test("the stack-breadcrumb region is left byte-for-byte alone", () => { 1 ); assert.equal(annotated.match(//g).length, 1); - assert.equal(annotated, `${body}\n\n${renderRegion(VERSION)}`); + assert.equal( + annotated, + `${STACK_REGION}\n\n${renderRegion(VERSION)}\n\n${DESCRIPTION}` + ); // 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)}`); + assert.equal( + republished, + `${STACK_REGION}\n\n${renderRegion(NEXT_VERSION)}\n\n${DESCRIPTION}` + ); +}); + +test("a region above the stack region is moved below it", () => { + const body = `${renderRegion(VERSION)}\n\n${STACK_REGION}\n\n${DESCRIPTION}`; + assert.equal( + upsertRegion(body, NEXT_VERSION), + `${STACK_REGION}\n\n${renderRegion(NEXT_VERSION)}\n\n${DESCRIPTION}` + ); +}); + +// The layout above is only stable if stack-breadcrumb.cjs re-lays the body to +// the same string. If it did not, the two writers would move the region back +// and forth on every run. +test("stack-breadcrumb's canonicalizeBody keeps the region where it is", () => { + const { canonicalizeBody } = require("./stack-breadcrumb.cjs"); + const annotated = upsertRegion(`${STACK_REGION}\n\n${DESCRIPTION}`, VERSION); + assert.equal(canonicalizeBody(annotated, STACK_REGION), annotated); + assert.equal(upsertRegion(annotated, VERSION), annotated); +}); + +test("a stack region that is not leading does not move the region", () => { + const body = `${DESCRIPTION}\n\n${STACK_REGION}`; + assert.equal( + upsertRegion(body, VERSION), + `${renderRegion(VERSION)}\n\n${body}` + ); }); test("a stack region containing the word nightly is not mistaken for one", () => { @@ -146,11 +179,11 @@ test("a stack region containing the word nightly is not mistaken for one", () => }); // 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}`; +// next publish must not leave it there — "always at the top" is the contract. +test("a region sitting mid-body is moved to the top, not duplicated", () => { + const body = `above\n\n${renderRegion(VERSION)}\n\nbelow`; const annotated = upsertRegion(body, NEXT_VERSION); - assert.equal(annotated, `${DESCRIPTION}\n\n${renderRegion(NEXT_VERSION)}`); + assert.equal(annotated, `${renderRegion(NEXT_VERSION)}\n\nabove\n\nbelow`); assert.equal(annotated.match(//g).length, 1); }); @@ -170,13 +203,13 @@ test("blank lines elsewhere in the description survive a republish", () => { ].join("\n"); const once = upsertRegion(authored, VERSION); - assert.equal(once, `${authored}\n\n${renderRegion(VERSION)}`); + assert.equal(once, `${renderRegion(VERSION)}\n\n${authored}`); // 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(twice, `${renderRegion(NEXT_VERSION)}\n\n${authored}`); + assert.ok(twice.endsWith(authored)); assert.equal(stripRegion(twice), authored); }); diff --git a/.github/workflows/release-cli-nightly.yml b/.github/workflows/release-cli-nightly.yml index 02721881..c4f946f7 100644 --- a/.github/workflows/release-cli-nightly.yml +++ b/.github/workflows/release-cli-nightly.yml @@ -168,7 +168,8 @@ # 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 +# at the TOP of that body, above the release notes (below a leading stack +# breadcrumb, if one is there). The region is REMOVED AND RE-INSERTED 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. @@ -671,7 +672,7 @@ jobs: # # 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 + # the next nightly re-inserts 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 diff --git a/openspec/changes/archive/2026-10-06-nightly-region-at-top/proposal.md b/openspec/changes/archive/2026-10-06-nightly-region-at-top/proposal.md new file mode 100644 index 00000000..dcf612fe --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-region-at-top/proposal.md @@ -0,0 +1,28 @@ +## Why + +The nightly build-info region is the part of the Version Packages pull request a +reviewer acts on: it is the install line for the build that carries the pending +changesets. At the end of the body it sits below the full release notes, which +grow with every changeset, so the one actionable line is the hardest to find. + +## What Changes + +- **`cli-nightly-builds`**: "A published nightly is announced on the pending + release pull request" places the region at the top of the body's description + instead of at the end. A leading stack-breadcrumb region stays first, and the + nightly region goes directly below it. +- `nightly-breadcrumb.cjs` inserts the region there. Below a leading stack + region is also where `stack-breadcrumb.cjs`'s `canonicalizeBody` leaves it, so + the two writers now agree on one layout. Before this, a stack re-lay moved the + region out of last place, and the next publish moved it back. + +## Impact + +- `.github/scripts/nightly-breadcrumb.cjs` and its tests, plus the header + comment in `.github/workflows/release-cli-nightly.yml`. +- No changeset: CI-only, nothing ships in `@taskless/cli`. + +## Delivery shape + +**Single PR.** The change is a placement rule, its implementation, and tests; +it fits one small diff, and the archive lands with it. diff --git a/openspec/changes/archive/2026-10-06-nightly-region-at-top/specs/cli-nightly-builds/spec.md b/openspec/changes/archive/2026-10-06-nightly-region-at-top/specs/cli-nightly-builds/spec.md new file mode 100644 index 00000000..a26f2aff --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-region-at-top/specs/cli-nightly-builds/spec.md @@ -0,0 +1,53 @@ +## MODIFIED Requirements + +### 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 place the region at the top of the body's description, SHALL replace any region a previous publish left rather than adding to it, and SHALL be restored if a human deletes it. When the body opens with a stack-breadcrumb region, the build-info region SHALL be placed directly below that region rather than above it, which is where the stack-breadcrumb writer's own layout leaves 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. A publish SHALL return the region to the top of the description 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 description SHALL begin with a build-info region naming the published package, version, commit, and build time + +#### Scenario: A stack breadcrumb stays first + +- **WHEN** the body opens with a stack-breadcrumb region +- **THEN** the build-info region SHALL be placed directly below it, and the stack-breadcrumb region SHALL be left unchanged + +#### 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 top of the description +- **THEN** the next publish SHALL move it back to the top 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 diff --git a/openspec/changes/archive/2026-10-06-nightly-region-at-top/tasks.md b/openspec/changes/archive/2026-10-06-nightly-region-at-top/tasks.md new file mode 100644 index 00000000..27cef80f --- /dev/null +++ b/openspec/changes/archive/2026-10-06-nightly-region-at-top/tasks.md @@ -0,0 +1,21 @@ +## 1. Spec + +- [x] 1.1 Restate "A published nightly is announced on the pending release pull + request" in full as a MODIFIED block under its existing title, placing + the region at the top of the description. +- [x] 1.2 Dry-run `openspec archive` and confirm every prior scenario survives. + +## 2. Implementation + +- [x] 2.1 `upsertRegion` inserts the region at the top, below a leading stack + region when there is one. +- [x] 2.2 Update the file header and the workflow header comment, and drop the + note about the two writers disagreeing, which no longer holds. + +## 3. Tests + +- [x] 3.1 Rewrite the placement tests from "at the end" to "at the top". +- [x] 3.2 Cover a region above a stack region (moved below it) and a stack + region that is not leading (ignored). +- [x] 3.3 Assert that `canonicalizeBody` in `stack-breadcrumb.cjs` leaves an + annotated body unchanged. diff --git a/openspec/specs/cli-nightly-builds/spec.md b/openspec/specs/cli-nightly-builds/spec.md index 7a5d317b..2d6a778b 100644 --- a/openspec/specs/cli-nightly-builds/spec.md +++ b/openspec/specs/cli-nightly-builds/spec.md @@ -197,16 +197,21 @@ 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. -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. +Each publish SHALL place the region at the top of the body's description, SHALL replace any region a previous publish left rather than adding to it, and SHALL be restored if a human deletes it. When the body opens with a stack-breadcrumb region, the build-info region SHALL be placed directly below that region rather than above it, which is where the stack-breadcrumb writer's own layout leaves 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. +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. A publish SHALL return the region to the top of the description 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 +- **THEN** that pull request's description SHALL begin with a build-info region naming the published package, version, commit, and build time + +#### Scenario: A stack breadcrumb stays first + +- **WHEN** the body opens with a stack-breadcrumb region +- **THEN** the build-info region SHALL be placed directly below it, and the stack-breadcrumb region SHALL be left unchanged #### Scenario: Repeated publishes replace the region @@ -215,8 +220,8 @@ The annotation SHALL depend on the publish having succeeded, and SHALL be perfor #### 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 +- **WHEN** another writer re-lays the body and the region no longer sits at the top of the description +- **THEN** the next publish SHALL move it back to the top rather than leave it in place or write a second one #### Scenario: An older build does not overwrite a newer one