Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 38 additions & 26 deletions .github/scripts/nightly-breadcrumb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,27 @@
* version already encodes both — `<n.m.k>-<yyyymmddhhmmss>x<sha>` — 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 `<!-- stack -->` … `<!-- /stack -->`, 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
* `<!-- stack -->` … `<!-- /stack -->` 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 `<!-- PR:N -->` 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 `<!-- PR:N -->`
* 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` —
Expand Down Expand Up @@ -119,6 +116,15 @@ const REGION_PATTERN = /<!-- nightly -->[\S\s]*?<!-- \/nightly -->/;
*/
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 =
/^<!-- stack [^>]*-->[\S\s]*?<!-- \/stack -->/;

/**
* The stamped prerelease identifier, anchored to the end: `-<14 digits>x<sha>`.
* Same grammar nightly-pack.cjs writes (design D3), read in the other
Expand Down Expand Up @@ -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");
}

/**
Expand Down
69 changes: 51 additions & 18 deletions .github/scripts/nightly-breadcrumb.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- /nightly -->"));
assert.equal(body, `${DESCRIPTION}\n\n${renderRegion(VERSION)}`);
assert.ok(body.startsWith("<!-- nightly -->"));
assert.ok(body.endsWith(DESCRIPTION));
assert.equal(body, `${renderRegion(VERSION)}\n\n${DESCRIPTION}`);
});

test("upsertRegion writes into an empty body without leading blank lines", () => {
Expand All @@ -95,7 +95,7 @@ test("repeated publishes replace the region, never accumulate", () => {
body = upsertRegion(body, NEXT_VERSION);
assert.equal(body.match(/<!-- nightly -->/g).length, 1);
assert.equal(body.match(/<!-- \/nightly -->/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));
});

Expand All @@ -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);
Expand All @@ -126,11 +127,43 @@ test("the stack-breadcrumb region is left byte-for-byte alone", () => {
1
);
assert.equal(annotated.match(/<!-- \/stack -->/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", () => {
Expand All @@ -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(/<!-- nightly -->/g).length, 1);
});

Expand All @@ -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);
});

Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release-cli-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- nightly -->` … `<!-- /nightly -->` 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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions openspec/changes/archive/2026-10-06-nightly-region-at-top/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading