Skip to content

Commit 06e7ca7

Browse files
committed
chore(release): model v3's pre-mode semantics in the gates, workflows and docs
- check-adr-0087-registration.mjs: exclude .changeset/pre/ from the audit surfaces (--list / --audit-stock) while the enforcing diff scan keeps judging it; PRE1-PRE3 fixtures - check-changeset-no-major.mjs: header only — v3 numbers, and the stale claim that both siblings pass --diff-filter=AM (they pass AMR since #7045) - cut-rc.yml / pr-automation.yml / docs/releases-maintenance.md: the prose asserting consumed changesets are recorded in .changeset/pre.json
1 parent e52213c commit 06e7ca7

5 files changed

Lines changed: 233 additions & 56 deletions

File tree

‎.github/workflows/cut-rc.yml‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -486,17 +486,39 @@ jobs:
486486
# cut: the pin bump and the version output are one atomic unit, and there is
487487
# never a main commit carrying a bumped pin but an unversioned tree.
488488
#
489-
# WHAT MAY BE IN IT — re-measured on the 17.0.0 -> 18.0.0-rc.0 train (a full
490-
# local `pnpm run version` in a throwaway clone over 199 pending changesets),
491-
# not assumed. 160 paths:
492-
# 77 modified package.json, 76 modified CHANGELOG.md,
493-
# .changeset/pre.json, the 3 major-boundary paths below, and the 3 doc
494-
# surfaces below. Zero deletions. `.changeset/pre.json` is UNTRACKED on the
495-
# first cut of a train (`changeset pre enter` just created it) and modified
496-
# on later ones; `git add -A` covers both. In PRE mode `changeset version`
497-
# does NOT delete the consumed changesets — it records them in pre.json and
498-
# they are removed at `changeset pre exit` — so a cut never races a lane PR
499-
# over a `.changeset/*.md` file it wants to keep.
489+
# WHAT MAY BE IN IT — re-measured under @changesets/cli v3 on the
490+
# 17.0.0 -> 17.1.0-rc.0 train (a full local `pnpm run version` in a throwaway
491+
# clone over 209 pending changesets), not assumed. 365 staged paths:
492+
# 76 modified package.json, 76 modified CHANGELOG.md, 209 changesets MOVED
493+
# into `.changeset/pre/`, `.changeset/pre.json`, and the 3 doc surfaces
494+
# below. `.changeset/pre.json` is UNTRACKED on the first cut of a train
495+
# (`changeset pre enter` just created it) and modified on later ones;
496+
# `git add -A` covers both.
497+
# The 3 major-boundary paths below did NOT appear on this train and are
498+
# allowed for the train that does move the major — as is the blank
499+
# template's own package.json, which is why the v2 measurement this block
500+
# used to carry counted 77 package.json against this one's 76: that train
501+
# was 17.0.0 -> 18.0.0-rc.0, so `sync-template-versions.mjs` had to rewrite
502+
# the template's `^17.0.0` pins. On a minor train it logs "already pins" and
503+
# writes nothing. The counts move with the SHAPE of the train; the
504+
# allowlist below is what does not have to.
505+
#
506+
# THE CONSUMED CHANGESETS MOVE — new in v3 (changesets#2190), and the one
507+
# thing about a cut that this file's prose used to get wrong. v2 left every
508+
# consumed `.changeset/*.md` on disk and recorded it in `pre.json`; v3 moves
509+
# it to `.changeset/pre/NAME.md` VERBATIM (measured: a moved file diffs
510+
# identical to its original, and git reports all 209 as `R100` renames), and
511+
# `pre.json` is `{"mode","tag"}` only — `changeset version` never rewrites
512+
# it. So in the worktree a cut is 209 deletions plus a new untracked
513+
# `.changeset/pre/` directory, and the claim this block used to make — "a
514+
# cut never races a lane PR over a `.changeset/*.md` file it wants to keep"
515+
# — is FALSE under v3. The cut moves tracked changeset paths, so a lane PR
516+
# that edits or deletes one of them conflicts; the rebase guard further down
517+
# is what catches it, and it already names `.changeset` in its conflict
518+
# surface. What does NOT change is this allowlist: `^\.changeset/` covers
519+
# `.changeset/pre/…` and `git add -A -- … .changeset …` stages the deletions
520+
# and the new directory together (measured: all 365 staged paths accepted,
521+
# nothing tracked left unstaged).
500522
# Three more paths can appear at a major boundary and are allowed for that
501523
# reason, all written by `sync-protocol-version.mjs` /
502524
# `sync-template-versions.mjs`: packages/spec/src/kernel/protocol-version.ts
@@ -848,7 +870,7 @@ jobs:
848870
echo "### Two things that are expected, not defects"
849871
echo
850872
echo "1. **The standing \`chore: version packages (rc)\` PR (#6208) may look stale.**"
851-
echo " Its changesets were consumed by this cut and are recorded in \`.changeset/pre.json\`."
873+
echo " Its changesets were consumed by this cut and moved to \`.changeset/pre/\` (v3)."
852874
echo " Whether it refreshes now depends on the push credential: pushes made with the"
853875
echo " Actions \`GITHUB_TOKEN\` trigger no workflow runs (GitHub's recursion guard), so"
854876
echo " \`version-pr\` does not fire until some later push to main; with a"

‎.github/workflows/pr-automation.yml‎

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -383,12 +383,25 @@ jobs:
383383
fi
384384
# Count changesets THIS PR adds (diff against the base commit), NOT
385385
# the whole .changeset directory. A global `find | wc -l` is unsound:
386-
# in pre-release (RC) mode `changeset version` RETAINS every consumed
387-
# .md file, so the directory is permanently non-empty and the gate can
388-
# never go red. #3373 merged a real spec/api-surface fix with no
389-
# changeset while this step happily reported "Found 104 changeset(s)".
390-
# Diffing against the merge base ignores that residue and sees only
391-
# what the PR itself introduced. It has to be the MERGE BASE and not
386+
# it counts main's pending stock, which has nothing to do with what this
387+
# PR wrote. #3373 merged a real spec/api-surface fix with no changeset
388+
# while this step happily reported "Found 104 changeset(s)".
389+
# Diffing against the merge base ignores that stock and sees only what
390+
# the PR itself introduced.
391+
#
392+
# That argument used to be stated in terms of pre-mode RESIDUE — under
393+
# @changesets/cli v2, `changeset version` in pre mode retained every
394+
# consumed .md file, so `.changeset/` was permanently non-empty for the
395+
# whole RC window. v3 moves consumed changesets into `.changeset/pre/`
396+
# instead (changesets#2190), so the root directory is no longer
397+
# permanently non-empty. The unsoundness is unchanged and so is this
398+
# step: a stock count is the wrong question in EVERY repo phase, not just
399+
# a polluted one. What the migration did have to be checked against is
400+
# the counter itself, because a cut now MOVES 209 tracked changeset
401+
# paths: measured on a real v3 cut commit, git reports them as `R100`
402+
# renames, so `--diff-filter=A` credits this PR with 0 of them (still 0
403+
# with `diff.renameLimit=1` forced; only `--no-renames` turns them into
404+
# 209 `A` rows, and nothing here passes it). It has to be the MERGE BASE and not
392405
# the payload's frozen `base.sha` -- see the base-resolution step above
393406
# (#6129); with the frozen sha this count silently included every
394407
# changeset main gained while the PR was open.
@@ -827,12 +840,19 @@ jobs:
827840
# same #6129 reason one defect along (#7005). This script used to read
828841
# the whole `.changeset` directory with no branch point, so its verdict
829842
# was a function of what main carried rather than of what the author
830-
# wrote. That was invisible while pre-mode held and would have become
831-
# visible all at once at `changeset pre exit`: 171 consumed-but-undeleted
832-
# major changesets sit on main until the post-exit `changeset version`
833-
# removes them, so every unlabelled PR open in that window would have
834-
# gone red listing files it never touched, with `allow-major` -- a label
835-
# meaning "a whole-stack major is intended HERE" -- as its only way out.
843+
# wrote. Under @changesets/cli v2 that was invisible while pre-mode held
844+
# and would have become visible all at once at `changeset pre exit`: 171
845+
# consumed-but-undeleted major changesets sat on main until the post-exit
846+
# `changeset version` removed them, so every unlabelled PR open in that
847+
# window would have gone red listing files it never touched, with
848+
# `allow-major` -- a label meaning "a whole-stack major is intended HERE"
849+
# -- as its only way out. v3 retires that particular detonation: each cut
850+
# moves its consumed changesets into `.changeset/pre/`, which the gate's
851+
# root-only reader does not enumerate, so the root stock stays bounded to
852+
# the unconsumed residue. It retires no line of the fix. `--base` is
853+
# correct because a verdict about what a PR INTRODUCED cannot be computed
854+
# without the fork -- #6129's argument, which never depended on how big
855+
# the stock was.
836856
if: >-
837857
steps.labels.outputs.skip != 'true'
838858
&& steps.labels_settled.outputs.skip != 'true'

‎docs/releases-maintenance.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,11 +303,14 @@ rediscovered at 2am:
303303
by itself.
304304

305305
**A stale #6208 after an rc cut is EXPECTED AND HARMLESS — do not "fix" it by hand.**
306-
Its changesets were consumed by the cut and are recorded in `.changeset/pre.json`; the
307-
PR is bookkeeping, it carries no publish capability by construction (`release.yml`
308-
passes the changesets action no `publish:` script), and it regenerates correctly at
309-
the next push to `main` or the next GA cut. Editing or force-refreshing it manually
310-
only risks putting a version commit somewhere the publish lane can reach.
306+
Its changesets were consumed by the cut and MOVED into `.changeset/pre/` — under
307+
`@changesets/cli` v3 that move is what "consumed" looks like on disk, and
308+
`.changeset/pre.json` carries `{"mode","tag"}` and nothing else, so it is not a
309+
record of what was consumed (it was, under v2). The PR is bookkeeping, it carries no
310+
publish capability by construction (`release.yml` passes the changesets action no
311+
`publish:` script), and it regenerates correctly at the next push to `main` or the
312+
next GA cut. Editing or force-refreshing it manually only risks putting a version
313+
commit somewhere the publish lane can reach.
311314

312315
**The runtime image is not built here.** `release.yml`'s `release-integrity` lane runs
313316
on every push to `main` and requests the image once the version is on npm, so it

‎scripts/check-adr-0087-registration.mjs‎

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,32 @@ const ADR_0087 = 'docs/adr/0087-metadata-protocol-upgrade-contract.md';
347347
/** `.changeset/README.md` is documentation, never a changeset. */
348348
const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') && !p.endsWith('/README.md');
349349

350+
/**
351+
* `.changeset/pre/NAME.md` is a CONSUMED prerelease changeset, not pending stock.
352+
*
353+
* New in `@changesets/cli` v3 (changesets#2190): a pre-mode `changeset version`
354+
* MOVES every changeset it consumes out of `.changeset/` and into
355+
* `.changeset/pre/`, verbatim, instead of leaving it in place and recording it in
356+
* `pre.json` the way v2 did. Measured on a v3 cut of this repo (209 pending
357+
* changesets, 17.0.0 -> 17.1.0-rc.0): all 209 move, git scores every one `R100`,
358+
* and `git ls-tree -r ... -- .changeset` at the cut lists all 209 under `pre/`.
359+
*
360+
* That `-r` is why this predicate exists. `changesetDirAt` below lists the
361+
* directory RECURSIVELY, so from the first v3 cut onwards the entire consumed
362+
* stock of the open RC window would flow into `--list` and `--audit-stock` — two
363+
* surfaces whose whole subject is the PENDING stock ("this gate judges diffs, not
364+
* stock"), and whose numbers a reader takes as the size of the backlog. Every one
365+
* of those changesets was judged by the diff scan on the PR that introduced it;
366+
* re-listing them daily for the length of a window is noise that grows.
367+
*
368+
* WHERE THIS DELIBERATELY DOES NOT APPLY: the enforcing scan. It reads the diff
369+
* through `isChangesetFile`, NOT through this predicate, so a changeset ADDED
370+
* under `.changeset/pre/` in a PR is still judged like any other. Filtering the
371+
* audit surfaces is a readability fix; filtering the verdict would be a bypass,
372+
* and `PRE2` in the self-test pins the difference.
373+
*/
374+
const isConsumedPrerelease = (p) => p.startsWith('.changeset/pre/');
375+
350376
/**
351377
* Split a changeset into its frontmatter bump entries and its body.
352378
*
@@ -1376,24 +1402,36 @@ export function mergeBase(base, head, cwd) {
13761402

13771403
/**
13781404
* Everything under `.changeset/` at a rev, split into "any entry at all" and
1379-
* "actual changesets", or `null` when the tree itself cannot be listed.
1405+
* "actual PENDING changesets", or `null` when the tree itself cannot be listed.
13801406
*
13811407
* The split is what lets `assertInputs` tell an EMPTY STOCK (zero pending
13821408
* changesets — the legitimate state of main right after a release cut, #8658)
13831409
* apart from a MISSING DIRECTORY (not even the tracked README.md/config.json
13841410
* found — unreadable input, still a #4690 refusal).
13851411
*
1412+
* `entries` stays UNFILTERED on purpose: it answers "is this directory readable
1413+
* at all", and a tree whose changesets have all moved into `.changeset/pre/` is a
1414+
* perfectly readable one. Narrowing it would turn the ordinary post-cut state of
1415+
* main into a #4690 refusal — the exact confusion #8658 removed. `changesets` is
1416+
* the narrowed half (see `isConsumedPrerelease`).
1417+
*
13861418
* @returns {{ entries: string[], changesets: string[] } | null}
13871419
*/
13881420
function changesetDirAt(rev, cwd) {
13891421
let out;
13901422
try { out = git(['ls-tree', '-r', '--name-only', rev, '--', '.changeset'], cwd); } catch { return null; }
13911423
const entries = out.split('\n').map((s) => s.trim()).filter(Boolean);
1392-
return { entries, changesets: entries.filter(isChangesetFile) };
1424+
return { entries, changesets: entries.filter((p) => isChangesetFile(p) && !isConsumedPrerelease(p)) };
13931425
}
13941426

1395-
/** Every changeset path present at a rev. */
1396-
function changesetsAt(rev, cwd) {
1427+
/**
1428+
* Every PENDING changeset path at a rev — the consumed prerelease stock under
1429+
* `.changeset/pre/` excluded (`isConsumedPrerelease`).
1430+
*
1431+
* Exported for `--self-test` S6/S8: the two consumers below print counts, and a
1432+
* count is the one thing an exit code cannot tell apart from "nothing ran".
1433+
*/
1434+
export function changesetsAt(rev, cwd) {
13971435
return changesetDirAt(rev, cwd)?.changesets ?? [];
13981436
}
13991437

@@ -3475,6 +3513,63 @@ function selfTest() {
34753513
assert(cls(CS({ body: TABLE_PRESCRIPTION })) === 'residue', 'S7: THE #6497 SHAPE -- published break + arrowless rewrite TABLE -- is RESIDUE');
34763514
}
34773515

3516+
// ---- PRE1-PRE3: `.changeset/pre/` is CONSUMED stock, not pending stock -----
3517+
//
3518+
// `@changesets/cli` v3 MOVES every changeset it consumes into `.changeset/pre/`
3519+
// at the cut that consumes it, and `changesetDirAt` lists `.changeset`
3520+
// RECURSIVELY (`ls-tree -r`). Without `isConsumedPrerelease` the entire consumed
3521+
// stock of an open RC window flows into `--list` and `--audit-stock`, two
3522+
// surfaces whose subject is the PENDING backlog. Measured on a real v3 cut of
3523+
// this repo's stock: 209 consumed changesets, every one of them returned by
3524+
// `git ls-tree -r --name-only <cut> -- .changeset`.
3525+
//
3526+
// PRE2 is the half that must NOT hold, and it is why the filter lives in
3527+
// `changesetsAt` rather than in `isChangesetFile`: the enforcing scan reads the
3528+
// diff through `isChangesetFile`, so a changeset ADDED under `.changeset/pre/`
3529+
// by a PR is judged like any other. Filter the verdict instead of the audit and
3530+
// `.changeset/pre/` becomes the place to put a breaking change you do not want
3531+
// read.
3532+
{
3533+
const { dir } = mk({
3534+
files: {
3535+
'.changeset/pre/consumed-one.md': CS({ body: 'consumed at the last cut\n\n**BREAKING** something\n' }),
3536+
'.changeset/pre/consumed-two.md': CS({ body: 'consumed at the last cut\n\n**BREAKING** something else\n' }),
3537+
},
3538+
});
3539+
const stock = changesetsAt('HEAD', dir);
3540+
assert(
3541+
stock.length === 1 && stock[0] === '.changeset/stock-breaking.md',
3542+
`PRE1: the audit surfaces read PENDING stock only -- two consumed changesets under .changeset/pre/ must not appear -- got ${JSON.stringify(stock)}`,
3543+
);
3544+
}
3545+
3546+
red('PRE2 a breaking changeset ADDED under .changeset/pre/ is still judged', run(mk({
3547+
files: { '.changeset/pre/smuggled.md': SIX048 },
3548+
pkgs: { '@objectstack/runtime': { dir: 'packages/runtime', private: false }, '@objectstack/spec': { dir: 'packages/spec', private: false } },
3549+
})), [/smuggled/, /no `adr-0087:` disposition marker/]);
3550+
3551+
{
3552+
// PRE3: `entries` stays UNFILTERED. A tree whose changesets have all moved
3553+
// into `.changeset/pre/` is the ordinary state of main mid-window; narrowing
3554+
// the readability probe as well would turn it into a #4690 refusal and undo
3555+
// #8658 by a different route. Pending stock is legitimately zero there.
3556+
const { dir } = mk({
3557+
files: {
3558+
'.changeset/stock-breaking.md': null,
3559+
'.changeset/pre/consumed.md': CS({ body: 'consumed\n\n**BREAKING** something\n' }),
3560+
},
3561+
});
3562+
const probs = assertInputs({ cwd: dir, head: 'HEAD' });
3563+
assert(
3564+
probs.length === 0,
3565+
`PRE3: a tree holding ONLY consumed changesets under .changeset/pre/ is readable input, not a #4690 refusal -- got: ${probs.join('|')}`,
3566+
);
3567+
assert(
3568+
changesetsAt('HEAD', dir).length === 0,
3569+
`PRE3: ...and its PENDING stock is legitimately zero -- got ${JSON.stringify(changesetsAt('HEAD', dir))}`,
3570+
);
3571+
}
3572+
34783573
assert(breakingDeclaration(parseChangeset(CS({ body: 'feat(spec)!: x\n' }))).breaking, 'P6: a conventional-commit bang is a declaration');
34793574
assert(!breakingDeclaration(parseChangeset(CS({ bumps: [['a', 'patch']], body: 'plain\n' }))).breaking, 'P7: a plain patch is not');
34803575

0 commit comments

Comments
 (0)