Skip to content

Commit bc6ccd3

Browse files
theCodeDriftclaude
andcommitted
fix(ci): match the version PR by base too, and keep the write monotonic
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=<owner>: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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
1 parent 8c74d51 commit bc6ccd3

4 files changed

Lines changed: 188 additions & 12 deletions

File tree

‎.github/scripts/nightly-breadcrumb.cjs‎

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@
3939
* on different names and neither pattern can match the other's markers; this
4040
* one never rewrites a region it does not own.
4141
*
42+
* THE CONVERSE DOES NOT HOLD, AND CANNOT BE FIXED FROM HERE. If the Version
43+
* Packages pull request is ever part of a stack that carries `<!-- PR:N -->`
44+
* regions, stack-breadcrumb.cjs's `canonicalizeBody` re-lays the whole body as
45+
* breadcrumb → description → carried regions. Its `ownDescription` strips only
46+
* the regions IT owns, so this one travels inside "description" and lands
47+
* ABOVE the carried blocks — no longer at the end. Nothing here runs at that
48+
* moment, so "at the end" is a property of each write rather than of the body
49+
* for all time (the spec says so in those words). The next publish moves the
50+
* region back, which is the same self-healing the lost-update window relies on
51+
* — see the workflow header. Teaching the other canonicalizer about this
52+
* region would be the real fix, and it belongs in that file, on a change that
53+
* can test it there.
54+
*
4255
* There is NO GitHub I/O here. The workflow fetches the pull request list with
4356
* `gh api` and applies the body with `gh api -X PATCH` (never `gh pr edit` —
4457
* its GraphQL path is broken by GitHub's Projects-classic deprecation), so this
@@ -70,9 +83,22 @@ const NIGHTLY_PACKAGE = "@taskless/cli-nightly";
7083
/** The head branch changesets opens its "Version Packages" pull request from. */
7184
const VERSION_PR_BRANCH = "changeset-release/main";
7285

86+
/** The base branch that pull request targets — matched, not assumed. */
87+
const DEFAULT_BRANCH = "main";
88+
7389
const REGION_OPEN = "<!-- nightly -->";
7490
const REGION_CLOSE = "<!-- /nightly -->";
7591

92+
/**
93+
* The install line inside a region, which is where the version a previous
94+
* publish wrote can be read back from. Built from `NIGHTLY_PACKAGE` rather than
95+
* spelled out, so renaming the package cannot leave the reader looking for a
96+
* name the renderer no longer writes.
97+
*/
98+
const INSTALL_LINE_PATTERN = new RegExp(
99+
`\`npx ${NIGHTLY_PACKAGE.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`)}@([^\`\\s]+)\``
100+
);
101+
76102
/**
77103
* The whole region, opening marker through closing marker. Non-greedy, so two
78104
* regions in a hand-mangled body are removed one at a time rather than
@@ -127,7 +153,9 @@ function parseStampedVersion(version) {
127153
if (Number.isNaN(parsed.getTime())) {
128154
throw new Error(`stamped version carries an impossible time: ${version}`);
129155
}
130-
return { builtAt, shortSha: sha };
156+
// `stamp` is the raw 14 digits: fixed-width and UTC, so a lexical compare of
157+
// two of them orders the builds (see isNewerBuild).
158+
return { builtAt, shortSha: sha, stamp };
131159
}
132160

133161
/** Render the region for `version`, markers included, with no trailing newline. */
@@ -197,11 +225,23 @@ function upsertRegion(body, version) {
197225
* returned, or `undefined` when there is none.
198226
*
199227
* `undefined` is a NORMAL answer, not an error: the branch exists only while
200-
* changesets are pending. The head ref is still matched here rather than
201-
* trusted from the query string, so a widened or mistyped filter cannot lead to
202-
* this writing its region onto an unrelated pull request.
228+
* changesets are pending.
229+
*
230+
* BOTH REFS ARE MATCHED HERE, not just the head, and neither is trusted from
231+
* the query string. GitHub allows several open pull requests from ONE head
232+
* branch to different bases, so `head=<owner>:changeset-release/main` alone can
233+
* return more than one — a second PR opened from that branch for testing, say —
234+
* and a `.find()` on the head ref would then write this region onto whichever
235+
* the API happened to list first. The one this workflow means is the one
236+
* changesets opens, which targets the default branch. The workflow's query
237+
* filters on both as well; this is the check that holds if that query is ever
238+
* widened or mistyped.
203239
*/
204-
function selectVersionPullRequest(pulls, branch = VERSION_PR_BRANCH) {
240+
function selectVersionPullRequest(
241+
pulls,
242+
branch = VERSION_PR_BRANCH,
243+
base = DEFAULT_BRANCH
244+
) {
205245
if (!Array.isArray(pulls)) {
206246
throw new Error(
207247
"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) {
212252
pull &&
213253
pull.head &&
214254
pull.head.ref === branch &&
255+
pull.base &&
256+
pull.base.ref === base &&
215257
(pull.state === undefined || pull.state === "open")
216258
);
217259
}
218260

261+
/**
262+
* The version named by the region already on `body`, or `undefined` when there
263+
* is none (or when the region has been edited past recognition).
264+
*/
265+
function readRegionVersion(body) {
266+
const region = REGION_PATTERN.exec(String(body ?? ""));
267+
if (!region) {
268+
return undefined;
269+
}
270+
const match = INSTALL_LINE_PATTERN.exec(region[0]);
271+
return match ? match[1] : undefined;
272+
}
273+
274+
/**
275+
* Is `candidate` a LATER build than `existing`? Used to keep this write
276+
* monotonic.
277+
*
278+
* ORDERING ACROSS RUNS IS NOT GUARANTEED BY `needs:`. That only orders jobs
279+
* within one run, and this workflow deliberately has no concurrency group (see
280+
* the file header — gate 2 is per-SHA, so two runs cannot both publish). Two
281+
* pushes to `main` in quick succession therefore start two independent runs,
282+
* and nothing stops the OLDER run's breadcrumb job from reaching the PATCH
283+
* after the newer one did. Without this check that older job would leave the
284+
* pull request advertising a build that has already been superseded — an
285+
* install line pointing at yesterday's nightly, with every run green.
286+
*
287+
* A concurrency group would be the wrong instrument: it would also cancel
288+
* in-progress PUBLISHES, trading a cosmetic staleness for a lost package. The
289+
* comparison is on the 14-digit UTC stamp, which is fixed-width, so a lexical
290+
* compare orders builds chronologically for any base version (design D3 chose
291+
* that layout for exactly this).
292+
*/
293+
function isNewerBuild(candidate, existing) {
294+
if (existing === undefined) {
295+
return true;
296+
}
297+
let existingStamp;
298+
try {
299+
existingStamp = parseStampedVersion(existing).stamp;
300+
} catch {
301+
// The region was edited into something unrecognizable. Treat it as absent
302+
// and rewrite it: a body carrying a broken region should converge.
303+
return true;
304+
}
305+
return parseStampedVersion(candidate).stamp > existingStamp;
306+
}
307+
219308
function requireValue(argv, index, flag) {
220309
const value = argv[index];
221310
if (value === undefined || value.length === 0 || value.startsWith("--")) {
@@ -271,8 +360,22 @@ function main() {
271360
return;
272361
}
273362

274-
const body = upsertRegion(pull.body ?? "", options.version);
275363
setOutput("pull_number", pull.number);
364+
365+
// Monotonic: an older run that reaches this point after a newer one wrote
366+
// must not roll the pull request back to the build it published. See
367+
// isNewerBuild — job ordering across runs is not guaranteed and a concurrency
368+
// group would cancel publishes to fix a cosmetic race.
369+
const present = readRegionVersion(pull.body ?? "");
370+
if (!isNewerBuild(options.version, present)) {
371+
console.log(
372+
`#${pull.number} already names ${present}, which is not older than ${options.version} — leaving it alone.`
373+
);
374+
setOutput("changed", "false");
375+
return;
376+
}
377+
378+
const body = upsertRegion(pull.body ?? "", options.version);
276379
if (body === (pull.body ?? "")) {
277380
console.log(`#${pull.number} already carries this build's region.`);
278381
setOutput("changed", "false");
@@ -285,12 +388,15 @@ function main() {
285388
}
286389

287390
module.exports = {
391+
DEFAULT_BRANCH,
288392
NIGHTLY_PACKAGE,
289393
REGION_CLOSE,
290394
REGION_OPEN,
291395
VERSION_PR_BRANCH,
292396
hasRegion,
397+
isNewerBuild,
293398
parseArguments,
399+
readRegionVersion,
294400
parseStampedVersion,
295401
renderRegion,
296402
selectVersionPullRequest,

‎.github/scripts/nightly-breadcrumb.test.cjs‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ const {
88
NIGHTLY_PACKAGE,
99
VERSION_PR_BRANCH,
1010
hasRegion,
11+
isNewerBuild,
1112
parseArguments,
13+
readRegionVersion,
1214
parseStampedVersion,
1315
renderRegion,
1416
selectVersionPullRequest,
@@ -36,6 +38,8 @@ test("parseStampedVersion reads the build time and sha back out of the version",
3638
assert.deepEqual(parseStampedVersion(VERSION), {
3739
builtAt: "2026-08-18 12:34:56",
3840
shortSha: "05b3c88",
41+
// The raw stamp is kept so builds can be ordered without re-parsing.
42+
stamp: "20260818123456",
3943
});
4044
});
4145

@@ -188,14 +192,61 @@ test("stripRegion returns a region-free body byte-for-byte", () => {
188192
assert.equal(stripRegion(body), body.trimEnd());
189193
});
190194

195+
/** The shape the pulls endpoint returns, trimmed to what the selector reads. */
196+
const pull = (number, ref, base = "main") => ({
197+
number,
198+
state: "open",
199+
head: { ref },
200+
base: { ref: base },
201+
body: "",
202+
});
203+
191204
test("selectVersionPullRequest finds the open Version Packages pull request", () => {
192-
const pulls = [
193-
{ number: 12, state: "open", head: { ref: "feat/other" }, body: "" },
194-
{ number: 74, state: "open", head: { ref: VERSION_PR_BRANCH }, body: "hi" },
195-
];
205+
const pulls = [pull(12, "feat/other"), pull(74, VERSION_PR_BRANCH)];
196206
assert.equal(selectVersionPullRequest(pulls).number, 74);
197207
});
198208

209+
// claude[bot] review on #133: GitHub allows several open PRs from one head
210+
// branch to different bases, so matching the head alone can annotate the wrong
211+
// one — whichever the API happened to list first.
212+
test("a same-head pull request targeting another base is not selected", () => {
213+
const decoy = pull(90, VERSION_PR_BRANCH, "some/test-base");
214+
const real = pull(74, VERSION_PR_BRANCH);
215+
216+
// Listed FIRST, so a head-only `.find()` would return it.
217+
assert.equal(selectVersionPullRequest([decoy, real]).number, 74);
218+
assert.equal(selectVersionPullRequest([decoy]), undefined);
219+
});
220+
221+
test("a pull request with no base is not selected", () => {
222+
assert.equal(
223+
selectVersionPullRequest([
224+
{ number: 74, state: "open", head: { ref: VERSION_PR_BRANCH } },
225+
]),
226+
undefined
227+
);
228+
});
229+
230+
test("readRegionVersion reads back the version a publish wrote", () => {
231+
assert.equal(readRegionVersion(upsertRegion(DESCRIPTION, VERSION)), VERSION);
232+
assert.equal(readRegionVersion(DESCRIPTION), undefined);
233+
assert.equal(readRegionVersion(""), undefined);
234+
});
235+
236+
// The cross-run race: `needs:` orders jobs inside ONE run, and this workflow
237+
// has no concurrency group on purpose, so an older run's breadcrumb job can
238+
// reach the write after a newer one did.
239+
test("isNewerBuild keeps the write monotonic", () => {
240+
assert.equal(isNewerBuild(NEXT_VERSION, VERSION), true);
241+
assert.equal(isNewerBuild(VERSION, NEXT_VERSION), false);
242+
assert.equal(isNewerBuild(VERSION, VERSION), false);
243+
assert.equal(isNewerBuild(VERSION, undefined), true);
244+
// A base-version bump does not reorder builds: the stamp decides.
245+
assert.equal(isNewerBuild("0.12.0-20260817000000xaaaaaaa", VERSION), false);
246+
// A region edited past recognition is treated as absent and rewritten.
247+
assert.equal(isNewerBuild(VERSION, "hand-edited"), true);
248+
});
249+
199250
// The whole point of the "no open pull request" branch: it is a normal state,
200251
// and a cosmetic breadcrumb must never fail a run that already published.
201252
test("no Version Packages pull request is undefined, not an error", () => {

‎.github/workflows/release-cli-nightly.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,14 @@ jobs:
444444
# A plain query, allowed to fail. No `|| echo '[]'`: a fallback here
445445
# would turn an API error into "no pull request found" and the run
446446
# would go green having silently skipped the update.
447-
gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main" > nightly-pulls.json
447+
#
448+
# `base=main` is not redundant with `head=`. GitHub allows several
449+
# open pull requests from ONE head branch to different bases, so the
450+
# head filter alone can return more than one and the script would
451+
# annotate whichever was listed first. The script re-checks both refs
452+
# for the same reason, so widening this query cannot silently widen
453+
# what gets written.
454+
gh api "repos/${REPO}/pulls?state=open&head=${OWNER}:changeset-release/main&base=main" > nightly-pulls.json
448455
449456
# Writes nightly-body.md only when the body actually changes, and
450457
# exits 0 with changed=false when there is no open Version Packages

‎openspec/specs/cli-nightly-builds/spec.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ When a nightly is published, the open pull request that carries the pending rele
163163

164164
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.
165165

166-
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.
166+
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.
167+
168+
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.
167169

168170
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.
169171

@@ -177,6 +179,16 @@ The annotation SHALL depend on the publish having succeeded, and SHALL be perfor
177179
- **WHEN** a second nightly is published while the same pull request is open
178180
- **THEN** the pull request SHALL carry exactly one build-info region, describing the most recent publish
179181

182+
#### Scenario: Another writer moves the region
183+
184+
- **WHEN** another writer re-lays the body and the region no longer sits at the end
185+
- **THEN** the next publish SHALL move it back to the end rather than leave it in place or write a second one
186+
187+
#### Scenario: An older build does not overwrite a newer one
188+
189+
- **WHEN** the region on the pull request names a build newer than the one being announced
190+
- **THEN** the body SHALL be left unchanged
191+
180192
#### Scenario: No open release pull request is not a failure
181193

182194
- **WHEN** a nightly is published and no pull request carrying pending release metadata is open

0 commit comments

Comments
 (0)