Skip to content

Commit 3d0a2da

Browse files
committed
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 74fa3cb, 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 06cab48 (18.0.0-next.5 -> 18.0.0), which leaves the same 8 changesets unconsumed that 74fa3cb annotated. Claude-Session: https://claude.ai/code/session_014C7q5Hm34g4EPdirar9SzN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3937697 commit 3d0a2da

1 file changed

Lines changed: 89 additions & 0 deletions

File tree

‎scripts/release-pending-publish.mjs‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,21 +541,62 @@ export function withLeadLine(text, line) {
541541
return { state: 'annotated', text: `${head}${eol}${line}${eol}${body.startsWith(eol) ? '' : eol}${body}` };
542542
}
543543

544+
/**
545+
* Orders two CLI versions (`X.Y.Z` or `X.Y.Z-tag.N`): negative when `a` is
546+
* older, positive when newer, 0 when equal. A prerelease sorts before its
547+
* release; prerelease identifiers compare numerically where both are numbers.
548+
*/
549+
export function compareVersions(a, b) {
550+
const split = (v) => {
551+
const [core, pre] = v.split('-', 2);
552+
return { core: core.split('.').map(Number), pre: pre === undefined ? null : pre.split('.') };
553+
};
554+
const x = split(a);
555+
const y = split(b);
556+
for (let i = 0; i < 3; i += 1) {
557+
const d = (x.core[i] ?? 0) - (y.core[i] ?? 0);
558+
if (d !== 0) return d;
559+
}
560+
if (x.pre === null || y.pre === null) return x.pre === y.pre ? 0 : x.pre === null ? 1 : -1;
561+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i += 1) {
562+
if (x.pre[i] === undefined) return -1;
563+
if (y.pre[i] === undefined) return 1;
564+
const nx = /^\d+$/.test(x.pre[i]);
565+
const ny = /^\d+$/.test(y.pre[i]);
566+
if (nx && ny && Number(x.pre[i]) !== Number(y.pre[i])) return Number(x.pre[i]) - Number(y.pre[i]);
567+
if (x.pre[i] !== y.pre[i]) return nx ? -1 : ny ? 1 : x.pre[i] < y.pre[i] ? -1 : 1;
568+
}
569+
return 0;
570+
}
571+
544572
/**
545573
* The last version commit on `head`'s first-parent chain, read WITHOUT
546574
* assuming full history: `found` needs the commit and its first parent both
547575
* present, because a graft-boundary commit has no parent and would read as the
548576
* version's introduction. `need-history` = the walk reached the shallow
549577
* boundary first; `none` = this history has no version commit, with the reason.
578+
*
579+
* A commit that moves the version BACK (a reverted cut: `git revert` of an
580+
* unpublished version commit, as 18.1.0's `74fa3cb6d` was by #25605) did not
581+
* introduce the version it returns to. The walk passes over it and over the
582+
* reverted cut's span, and keeps looking for the forward commit that did.
583+
* Read as a version commit, it made every changeset the revert restored look
584+
* shipped in the version main went back to.
550585
*/
551586
export function locateLastVersionCommit({ cwd, head }) {
552587
const shallow = gitOk(cwd, ['rev-parse', '--is-shallow-repository']).trim() !== 'false';
553588
const headSha = gitOk(cwd, ['rev-parse', '--verify', `${head}^{commit}`]).trim();
554589
const version = versionAt(cwd, headSha);
555590
if (version === null) return { state: 'none', headSha, reason: `${headSha.slice(0, 10)} carries no ${CLI_MANIFEST}` };
556591
const touching = gitOk(cwd, ['log', '--first-parent', '--format=%H', headSha, '--', CLI_MANIFEST]).split('\n').filter(Boolean);
592+
let reverted = false;
557593
for (const commit of touching) {
558594
const here = versionAt(cwd, commit);
595+
if (reverted) {
596+
// Inside a reverted cut's span: commits carrying the version it undid.
597+
if (here !== version) continue;
598+
reverted = false;
599+
}
559600
if (here !== version) {
560601
return {
561602
state: 'none',
@@ -574,6 +615,10 @@ export function locateLastVersionCommit({ cwd, head }) {
574615
if (before === null) {
575616
return { state: 'none', headSha, reason: `${CLI_MANIFEST} is created at ${commit.slice(0, 10)}, and its creation is not a release` };
576617
}
618+
if (compareVersions(before, version) > 0) {
619+
reverted = true;
620+
continue;
621+
}
577622
return { state: 'found', headSha, version, from: before, versionCommit: commit };
578623
}
579624
return shallow
@@ -1256,6 +1301,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
12561301
'annotate-shipped -> a second run writes nothing': 2,
12571302
'annotate-shipped -> a changeset whose first body line already says Shipped in is left byte-identical': 2,
12581303
'annotate-shipped -> outside pre mode, where the version pass deletes what it consumes': 2,
1304+
'annotate-shipped -> a reverted cut is not a version commit: the walk reads the forward cut before it': 3,
12591305
'annotate-shipped -> a depth-1 clone deepens itself to the version commit; with no reachable history it warns, annotates nothing and exits 0': 4,
12601306
'annotate-shipped -> no version commit, an unpublished or unreadable version, an error anywhere: a warning or a plain line, nothing written, exit 0': 5,
12611307
});
@@ -1645,6 +1691,49 @@ async function selfTest() {
16451691
'with the "release" form of the line',
16461692
);
16471693

1694+
battery('annotate-shipped -> a reverted cut is not a version commit: the walk reads the forward cut before it');
1695+
// The 18.1.0 shape: a 17.7.0 cut left y.md unconsumed (above); the next cut
1696+
// (17.8.0) consumed every pending changeset, was never published, and was
1697+
// reverted, which restored them all and moved the CLI back to 17.7.0.
1698+
const rv = fixture();
1699+
const rPut = (path) => {
1700+
mkdirSync(dirname(join(rv.dir, path)), { recursive: true });
1701+
writeFileSync(join(rv.dir, path), `---\n'${CLI_PACKAGE}': minor\n---\n\n${path}\n`);
1702+
};
1703+
rv.writeCli('17.6.0');
1704+
rPut('.changeset/x.md');
1705+
rv.commit('base: one changeset pending');
1706+
rv.g('checkout', '-q', '-b', 'version-pr');
1707+
rv.g('rm', '-q', '.changeset/x.md');
1708+
rv.writeCli('17.7.0');
1709+
rv.commit('chore: version packages');
1710+
rv.g('checkout', '-q', 'main');
1711+
rPut('.changeset/y.md');
1712+
rv.commit('a landing after the cut');
1713+
rv.g('merge', '-q', '--squash', 'version-pr');
1714+
const rForward = rv.commit('chore: version packages (landed behind main)');
1715+
rPut('.changeset/z.md');
1716+
rv.commit('a landing after 17.7.0');
1717+
rv.g('rm', '-q', '.changeset/y.md', '.changeset/z.md');
1718+
rv.writeCli('17.8.0');
1719+
const rCut = rv.commit('chore: version packages (17.8.0, never published)');
1720+
rv.g('revert', '--no-edit', rCut);
1721+
const rRevert = gitOk(rv.dir, ['rev-parse', 'HEAD']).trim();
1722+
const afterRevert = annotate(rv.dir);
1723+
check(
1724+
locateLastVersionCommit({ cwd: rv.dir, head: rRevert }).versionCommit === rForward && compareVersions('17.8.0', '17.7.0') > 0 &&
1725+
compareVersions('18.0.0-next.2', '18.0.0') < 0 && compareVersions('18.0.0-next.10', '18.0.0-next.2') > 0,
1726+
'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',
1727+
);
1728+
check(
1729+
afterRevert.verdict === 'done' && afterRevert.versionCommit === rForward && afterRevert.annotated.join() === '.changeset/y.md',
1730+
'so only y.md, which 17.7.0 really left unconsumed, is annotated',
1731+
);
1732+
check(
1733+
!readFileSync(join(rv.dir, '.changeset/z.md'), 'utf8').includes(SHIPPED_MARK),
1734+
'and z.md, which the revert restored but 17.7.0 never carried, is not stamped as shipped in 17.7.0',
1735+
);
1736+
16481737
battery('annotate-shipped -> a depth-1 clone deepens itself to the version commit; with no reachable history it warns, annotates nothing and exits 0');
16491738
q.g('checkout', '-q', '--', '.changeset');
16501739
q.g('config', 'uploadpack.allowReachableSHA1InWant', 'true');

0 commit comments

Comments
 (0)