diff --git a/packages/spec/src/conversions/retired-after.census.test.ts b/packages/spec/src/conversions/retired-after.census.test.ts index 61df3941bea..0feca20e455 100644 --- a/packages/spec/src/conversions/retired-after.census.test.ts +++ b/packages/spec/src/conversions/retired-after.census.test.ts @@ -32,6 +32,23 @@ import { ALL_CONVERSIONS } from './registry.js'; * version from the census's last release up to the label — it landed either * before the bump or after it, and only the new tarball can say which. * Refreshing the census after the publish closes the tolerance again. + * + * The label is read on its RELEASE TRIPLE. Changesets pre mode writes a + * prerelease label (`18.0.0-next.0`) into this package on the Version Packages + * PR, and onto `main` once that lands, so every comparison against the label + * uses `x.y.z` with the prerelease suffix dropped: `18.0.0-next.N` reads as + * `18.0.0`. That is the reading the artifact door gives the runtime label (it + * compares `x.y.z` triples and tolerates a suffix), and on a stable label it is + * the label itself, so no assertion changes meaning there. Under a prerelease + * label the census never records that label's tarball (it holds stable + * releases only), so the release-pending tolerance above stays open, spanning + * `[last censused release, release triple]`, until the stable release is + * published and censused, when the PUBLISHED rule re-judges every stamp. Which + * stamp a retirement landing under a prerelease label should carry is the + * artifact door's window question, and this file follows that rule rather + * than settling it. Only the label gets this reading: a census version and a + * `retiredAfter` stamp stay stable `x.y.z`, and a label that is neither + * `x.y.z` nor `x.y.z-PRERELEASE` still throws. */ type Triple = [number, number, number]; @@ -69,6 +86,22 @@ function cmp(a: string, b: string): number { return 0; } +/** + * The release triple of a package label: `x.y.z` as is, `x.y.z-PRERELEASE` + * with the suffix dropped (`18.0.0-next.0` reads as `18.0.0`). Anything else + * throws. See the file docblock for why the label alone is read this way. + */ +function releaseTriple(label: string): string { + const m = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(label); + if (!m) throw new Error(`not an x.y.z or x.y.z-prerelease package label: '${label}'`); + return m[1]!; +} + +/** The label as every comparison below reads it. */ +const RELEASE = releaseTriple(LABEL); +/** The label as a failure message names it. */ +const LABEL_SHOWN = LABEL === RELEASE ? `'${LABEL}'` : `'${LABEL}' (release triple '${RELEASE}')`; + const LAST_CENSUSED = CENSUS.releases.at(-1)!.version; /** @@ -91,7 +124,7 @@ describe('retiredAfter census — every retired entry stamped from the published for (let i = 1; i < versions.length; i++) { expect(cmp(versions[i - 1]!, versions[i]!), `${versions[i - 1]} < ${versions[i]}`).toBe(-1); } - expect(cmp(LAST_CENSUSED, LABEL), `census (${LAST_CENSUSED}) is ahead of the label (${LABEL})`).toBeLessThanOrEqual(0); + expect(cmp(LAST_CENSUSED, RELEASE), `census (${LAST_CENSUSED}) is ahead of the label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); for (const r of CENSUS.releases) { expect(r.integrity).toMatch(/^sha512-/); expect([...r.retired].sort()).toEqual(r.retired); @@ -102,7 +135,7 @@ describe('retiredAfter census — every retired entry stamped from the published expect(RETIRED.length).toBeGreaterThan(0); for (const c of RETIRED) { expect(c.retiredAfter, c.id).toMatch(/^\d+\.\d+\.\d+$/); - expect(cmp(c.retiredAfter!, LABEL), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL}`).toBeLessThanOrEqual(0); + expect(cmp(c.retiredAfter!, RELEASE), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); } }); @@ -126,17 +159,44 @@ describe('retiredAfter census — every retired entry stamped from the published }); it('every UNPUBLISHED entry carries the package label (the release-pending range while the census trails it)', () => { - const pending = cmp(LABEL, LAST_CENSUSED) > 0; + const pending = cmp(RELEASE, LAST_CENSUSED) > 0; const wrong: string[] = []; for (const c of RETIRED) { if (publishedRetiredAfter(c.id) !== null) continue; const v = c.retiredAfter!; - if (!pending && v !== LABEL) { - wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label '${LABEL}'`); - } else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, LABEL) > 0)) { - wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${LABEL}] — refresh the census (${REGEN})`); + if (!pending && v !== RELEASE) { + wrong.push(`${c.id}: retiredAfter '${v}', but no published tarball retires it, so it takes the label ${LABEL_SHOWN}`); + } else if (pending && (cmp(v, LAST_CENSUSED) < 0 || cmp(v, RELEASE) > 0)) { + wrong.push(`${c.id}: retiredAfter '${v}' is outside [${LAST_CENSUSED}, ${RELEASE}] — refresh the census (${REGEN})`); } } expect(wrong).toEqual([]); }); }); + +describe('the package label is read on its release triple', () => { + it('a stable label is its own release triple', () => { + expect(releaseTriple('17.7.0')).toBe('17.7.0'); + expect(releaseTriple('18.0.0')).toBe('18.0.0'); + }); + + it('a prerelease label reads as its release triple', () => { + expect(releaseTriple('18.0.0-next.0')).toBe('18.0.0'); + expect(releaseTriple('18.0.0-next.12')).toBe('18.0.0'); + expect(releaseTriple('18.1.0-rc.1')).toBe('18.1.0'); + // ...and so compares at, never above, the release it precedes. + expect(cmp('17.7.0', releaseTriple('18.0.0-next.0'))).toBe(-1); + expect(cmp('18.0.0', releaseTriple('18.0.0-next.0'))).toBe(0); + }); + + it('a malformed label still throws, naming the label', () => { + for (const bad of ['', 'next', '18', '18.0', 'v18.0.0', '18.0.0-', '18.0.0-next..0', '18.0.0.0', ' 18.0.0']) { + expect(() => releaseTriple(bad), JSON.stringify(bad)).toThrow(`'${bad}'`); + } + }); + + it('the release-triple reading is the label\'s alone: a census version or a stamp stays stable x.y.z', () => { + expect(() => triple('18.0.0-next.0')).toThrow(`'18.0.0-next.0'`); + expect(() => cmp('18.0.0-next.0', '17.7.0')).toThrow(`'18.0.0-next.0'`); + }); +});