From 3d0a2dae175a78a8ec331641ccb0aa4d25ec57ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:41:21 +0000 Subject: [PATCH] fix(release): annotate-shipped passes over a reverted cut instead of reading it as the version commit #25605 reverted the unpublished 18.1.0 version commit 74fa3cb6d, moving the CLI back to 18.0.0. locateLastVersionCommit took that revert as the commit that introduced 18.0.0 and, 18.0.0 being on npm, stamped every changeset the revert restored with "Shipped in 18.0.0": 412 wrong lead lines in the regenerated Version Packages PR #25606. A commit that moves the version back now passes over itself and the reverted cut's span, and the walk reads the forward cut before it. On main that is 06cab4849a (18.0.0-next.5 -> 18.0.0), which leaves the same 8 changesets unconsumed that 74fa3cb6d annotated. Claude-Session: https://claude.ai/code/session_014C7q5Hm34g4EPdirar9SzN Co-authored-by: Claude --- scripts/release-pending-publish.mjs | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/scripts/release-pending-publish.mjs b/scripts/release-pending-publish.mjs index 4bfd5209005..6a19d91b5a0 100644 --- a/scripts/release-pending-publish.mjs +++ b/scripts/release-pending-publish.mjs @@ -541,12 +541,47 @@ export function withLeadLine(text, line) { return { state: 'annotated', text: `${head}${eol}${line}${eol}${body.startsWith(eol) ? '' : eol}${body}` }; } +/** + * Orders two CLI versions (`X.Y.Z` or `X.Y.Z-tag.N`): negative when `a` is + * older, positive when newer, 0 when equal. A prerelease sorts before its + * release; prerelease identifiers compare numerically where both are numbers. + */ +export function compareVersions(a, b) { + const split = (v) => { + const [core, pre] = v.split('-', 2); + return { core: core.split('.').map(Number), pre: pre === undefined ? null : pre.split('.') }; + }; + const x = split(a); + const y = split(b); + for (let i = 0; i < 3; i += 1) { + const d = (x.core[i] ?? 0) - (y.core[i] ?? 0); + if (d !== 0) return d; + } + if (x.pre === null || y.pre === null) return x.pre === y.pre ? 0 : x.pre === null ? 1 : -1; + for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i += 1) { + if (x.pre[i] === undefined) return -1; + if (y.pre[i] === undefined) return 1; + const nx = /^\d+$/.test(x.pre[i]); + const ny = /^\d+$/.test(y.pre[i]); + if (nx && ny && Number(x.pre[i]) !== Number(y.pre[i])) return Number(x.pre[i]) - Number(y.pre[i]); + if (x.pre[i] !== y.pre[i]) return nx ? -1 : ny ? 1 : x.pre[i] < y.pre[i] ? -1 : 1; + } + return 0; +} + /** * The last version commit on `head`'s first-parent chain, read WITHOUT * assuming full history: `found` needs the commit and its first parent both * present, because a graft-boundary commit has no parent and would read as the * version's introduction. `need-history` = the walk reached the shallow * boundary first; `none` = this history has no version commit, with the reason. + * + * A commit that moves the version BACK (a reverted cut: `git revert` of an + * unpublished version commit, as 18.1.0's `74fa3cb6d` was by #25605) did not + * introduce the version it returns to. The walk passes over it and over the + * reverted cut's span, and keeps looking for the forward commit that did. + * Read as a version commit, it made every changeset the revert restored look + * shipped in the version main went back to. */ export function locateLastVersionCommit({ cwd, head }) { const shallow = gitOk(cwd, ['rev-parse', '--is-shallow-repository']).trim() !== 'false'; @@ -554,8 +589,14 @@ export function locateLastVersionCommit({ cwd, head }) { const version = versionAt(cwd, headSha); if (version === null) return { state: 'none', headSha, reason: `${headSha.slice(0, 10)} carries no ${CLI_MANIFEST}` }; const touching = gitOk(cwd, ['log', '--first-parent', '--format=%H', headSha, '--', CLI_MANIFEST]).split('\n').filter(Boolean); + let reverted = false; for (const commit of touching) { const here = versionAt(cwd, commit); + if (reverted) { + // Inside a reverted cut's span: commits carrying the version it undid. + if (here !== version) continue; + reverted = false; + } if (here !== version) { return { state: 'none', @@ -574,6 +615,10 @@ export function locateLastVersionCommit({ cwd, head }) { if (before === null) { return { state: 'none', headSha, reason: `${CLI_MANIFEST} is created at ${commit.slice(0, 10)}, and its creation is not a release` }; } + if (compareVersions(before, version) > 0) { + reverted = true; + continue; + } return { state: 'found', headSha, version, from: before, versionCommit: commit }; } return shallow @@ -1256,6 +1301,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'annotate-shipped -> a second run writes nothing': 2, 'annotate-shipped -> a changeset whose first body line already says Shipped in is left byte-identical': 2, 'annotate-shipped -> outside pre mode, where the version pass deletes what it consumes': 2, + 'annotate-shipped -> a reverted cut is not a version commit: the walk reads the forward cut before it': 3, 'annotate-shipped -> a depth-1 clone deepens itself to the version commit; with no reachable history it warns, annotates nothing and exits 0': 4, 'annotate-shipped -> no version commit, an unpublished or unreadable version, an error anywhere: a warning or a plain line, nothing written, exit 0': 5, }); @@ -1645,6 +1691,49 @@ async function selfTest() { 'with the "release" form of the line', ); + battery('annotate-shipped -> a reverted cut is not a version commit: the walk reads the forward cut before it'); + // The 18.1.0 shape: a 17.7.0 cut left y.md unconsumed (above); the next cut + // (17.8.0) consumed every pending changeset, was never published, and was + // reverted, which restored them all and moved the CLI back to 17.7.0. + const rv = fixture(); + const rPut = (path) => { + mkdirSync(dirname(join(rv.dir, path)), { recursive: true }); + writeFileSync(join(rv.dir, path), `---\n'${CLI_PACKAGE}': minor\n---\n\n${path}\n`); + }; + rv.writeCli('17.6.0'); + rPut('.changeset/x.md'); + rv.commit('base: one changeset pending'); + rv.g('checkout', '-q', '-b', 'version-pr'); + rv.g('rm', '-q', '.changeset/x.md'); + rv.writeCli('17.7.0'); + rv.commit('chore: version packages'); + rv.g('checkout', '-q', 'main'); + rPut('.changeset/y.md'); + rv.commit('a landing after the cut'); + rv.g('merge', '-q', '--squash', 'version-pr'); + const rForward = rv.commit('chore: version packages (landed behind main)'); + rPut('.changeset/z.md'); + rv.commit('a landing after 17.7.0'); + rv.g('rm', '-q', '.changeset/y.md', '.changeset/z.md'); + rv.writeCli('17.8.0'); + const rCut = rv.commit('chore: version packages (17.8.0, never published)'); + rv.g('revert', '--no-edit', rCut); + const rRevert = gitOk(rv.dir, ['rev-parse', 'HEAD']).trim(); + const afterRevert = annotate(rv.dir); + check( + locateLastVersionCommit({ cwd: rv.dir, head: rRevert }).versionCommit === rForward && compareVersions('17.8.0', '17.7.0') > 0 && + compareVersions('18.0.0-next.2', '18.0.0') < 0 && compareVersions('18.0.0-next.10', '18.0.0-next.2') > 0, + 'the revert back to 17.7.0 is passed over with the reverted 17.8.0 cut; the 17.7.0 cut is the version commit, and versions order numerically', + ); + check( + afterRevert.verdict === 'done' && afterRevert.versionCommit === rForward && afterRevert.annotated.join() === '.changeset/y.md', + 'so only y.md, which 17.7.0 really left unconsumed, is annotated', + ); + check( + !readFileSync(join(rv.dir, '.changeset/z.md'), 'utf8').includes(SHIPPED_MARK), + 'and z.md, which the revert restored but 17.7.0 never carried, is not stamped as shipped in 17.7.0', + ); + battery('annotate-shipped -> a depth-1 clone deepens itself to the version commit; with no reachable history it warns, annotates nothing and exits 0'); q.g('checkout', '-q', '--', '.changeset'); q.g('config', 'uploadpack.allowReachableSHA1InWant', 'true');