diff --git a/.changeset/23042-retired-after-prerelease-stamp.md b/.changeset/23042-retired-after-prerelease-stamp.md new file mode 100644 index 00000000000..aeea67fcd23 --- /dev/null +++ b/.changeset/23042-retired-after-prerelease-stamp.md @@ -0,0 +1,5 @@ +--- +'@objectstack/spec': patch +--- + +The TSDoc on `MetadataConversion.retiredAfter` now says what a retirement stamps under a prerelease package label. Under a stable label `x.y.z` it is the label itself, as before. Under a prerelease label `x.y.z-PRERELEASE` (Changesets pre mode, such as `18.0.0-next.N`) it is the newest published stable release, which sits below `x.y.z`: never the prerelease label, which is not a stable `x.y.z`, and never `x.y.z`, the first stable release that carries the entry retired. The old wording, "the package's version label at the moment the retirement lands", had no value that type-checks under a prerelease label. A stable release published later that does not carry the retirement (a `17.x` maintenance release during the 18 prerelease) still accepted the old shape, so it becomes the value. Text only: `retiredAfter` keeps its `${number}.${number}.${number}` type, and the accepted set does not change. diff --git a/packages/spec/src/conversions/retired-after.census.test.ts b/packages/spec/src/conversions/retired-after.census.test.ts index 0feca20e455..7607c5430a2 100644 --- a/packages/spec/src/conversions/retired-after.census.test.ts +++ b/packages/spec/src/conversions/retired-after.census.test.ts @@ -24,29 +24,40 @@ import { ALL_CONVERSIONS } from './registry.js'; * - PUBLISHED entry (retired in some censused tarball): the value is the * stable release just before the FIRST tarball that carries it retired. * - UNPUBLISHED entry (retired in no censused tarball): the value is the - * package's own version label — the label at the moment it lands, which is - * the last release — so a retirement landing after a release bump cannot be - * stamped low. One tolerance, and only one: while the label is AHEAD of the + * newest stable release at the moment it lands, read off the package's own + * version label. Under a stable label that is the label itself — the label + * at the moment it lands is the last release — so a retirement landing + * after a release bump cannot be stamped low. Under a prerelease label it is + * the newest published stable release, below the label's release triple + * (see below). One tolerance, and only one: while the label is AHEAD of the * census's last release (the release PR bumped it and the census has not * yet recorded that release's tarball), an unpublished entry may carry any * 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 + * The label is read on its RELEASE TRIPLE, with semver precedence. 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). A prerelease precedes its release, so "at or below the label" means + * strictly below `x.y.z` under a prerelease label: `18.0.0-next.N` admits + * `17.7.0` and refuses `18.0.0`. That is the PUBLISHED rule applied before the + * fact: a retirement landing under a prerelease label is first carried retired + * by the stable `x.y.z` (the census holds stable releases only), so its value + * is the stable release just before that tarball, never `x.y.z` itself — which + * is refused now rather than at the census refresh after `x.y.z` ships. On a + * stable label the reading is the label itself, inclusive, so no assertion + * changes meaning there. Under a prerelease label the census never records + * that label's tarball, 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. A + * stable maintenance release published meanwhile without the retirement (a + * `17.x` release during the 18 prerelease) still accepted the old shape, so + * once censused it raises the range's lower bound and the entry is re-stamped + * to 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. */ @@ -97,10 +108,50 @@ function releaseTriple(label: string): string { return m[1]!; } +/** A package label as a failure message names it. */ +function labelShown(label: string): string { + const release = releaseTriple(label); + return label === release ? `'${label}'` : `'${label}' (release triple '${release}')`; +} + +/** + * Whether the stable version `v` is at or below the package label, by semver + * precedence: a stable label `x.y.z` admits `x.y.z` itself; a prerelease label + * `x.y.z-PRERELEASE` precedes its release, so only a version strictly below + * `x.y.z` is at or below it. See the file docblock. + */ +function atOrBelowLabel(v: string, label: string): boolean { + const release = releaseTriple(label); + return label === release ? cmp(v, release) <= 0 : cmp(v, release) < 0; +} + +/** + * Why an UNPUBLISHED entry's stamp `v` breaks the docblock's UNPUBLISHED rule + * under `label` and a census whose last release is `lastCensused`, or `null` + * when it does not. A prerelease label never takes the exact-label branch: the + * census never records its tarball, so the release-pending range stays open. + */ +function unpublishedStampProblem(v: string, label: string, lastCensused: string): string | null { + const release = releaseTriple(label); + const prerelease = label !== release; + if (!prerelease && cmp(release, lastCensused) <= 0) { + return v === release ? null : `retiredAfter '${v}', but no published tarball retires it, so it takes the label ${labelShown(label)}`; + } + if (cmp(v, lastCensused) >= 0 && atOrBelowLabel(v, label)) return null; + if (!prerelease) return `retiredAfter '${v}' is outside [${lastCensused}, ${release}] — refresh the census (${REGEN})`; + return ( + `retiredAfter '${v}' is outside [${lastCensused}, ${release}): under the prerelease label ${labelShown(label)}, ` + + `a retirement stamps the newest published stable release, never '${release}' — the first stable release that carries it retired ` + + `(after a stable publish, refresh the census: ${REGEN})` + ); +} + /** The label as every comparison below reads it. */ const RELEASE = releaseTriple(LABEL); +/** Whether the label is a prerelease of {@link RELEASE}, which it then precedes. */ +const PRERELEASE = LABEL !== RELEASE; /** The label as a failure message names it. */ -const LABEL_SHOWN = LABEL === RELEASE ? `'${LABEL}'` : `'${LABEL}' (release triple '${RELEASE}')`; +const LABEL_SHOWN = labelShown(LABEL); const LAST_CENSUSED = CENSUS.releases.at(-1)!.version; @@ -124,7 +175,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, RELEASE), `census (${LAST_CENSUSED}) is ahead of the label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); + expect(atOrBelowLabel(LAST_CENSUSED, LABEL), `census (${LAST_CENSUSED}) is ahead of the label ${LABEL_SHOWN}`).toBe(true); for (const r of CENSUS.releases) { expect(r.integrity).toMatch(/^sha512-/); expect([...r.retired].sort()).toEqual(r.retired); @@ -135,7 +186,11 @@ 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!, RELEASE), `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL_SHOWN}`).toBeLessThanOrEqual(0); + expect( + atOrBelowLabel(c.retiredAfter!, LABEL), + `${c.id}: retiredAfter ${c.retiredAfter} > label ${LABEL_SHOWN}` + + (PRERELEASE ? `, which precedes '${RELEASE}': stamp the newest published stable release` : ''), + ).toBe(true); } }); @@ -158,22 +213,43 @@ describe('retiredAfter census — every retired entry stamped from the published expect(published).toBeGreaterThan(0); }); - it('every UNPUBLISHED entry carries the package label (the release-pending range while the census trails it)', () => { - const pending = cmp(RELEASE, LAST_CENSUSED) > 0; + it('every UNPUBLISHED entry carries the newest stable release (the release-pending range while the census trails the label)', () => { const wrong: string[] = []; for (const c of RETIRED) { if (publishedRetiredAfter(c.id) !== null) continue; - const v = c.retiredAfter!; - 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})`); - } + const problem = unpublishedStampProblem(c.retiredAfter!, LABEL, LAST_CENSUSED); + if (problem !== null) wrong.push(`${c.id}: ${problem}`); } expect(wrong).toEqual([]); }); }); +describe('the UNPUBLISHED rule under a simulated label', () => { + it('a stable label admits itself: the release-pending range is inclusive, and a census at the label takes the exact label', () => { + for (const v of ['17.4.0', '17.5.0', '17.7.0']) expect(unpublishedStampProblem(v, '17.7.0', '17.4.0'), v).toBeNull(); + for (const v of ['17.3.0', '17.7.1', '18.0.0']) expect(unpublishedStampProblem(v, '17.7.0', '17.4.0'), v).not.toBeNull(); + expect(unpublishedStampProblem('17.7.0', '17.7.0', '17.7.0')).toBeNull(); + expect(unpublishedStampProblem('17.6.0', '17.7.0', '17.7.0')).not.toBeNull(); + }); + + it('a prerelease label refuses its own release triple and admits the newest published stable release', () => { + for (const label of ['18.0.0-next.0', '18.0.0-next.12']) { + for (const v of ['17.4.0', '17.7.0', '17.7.1']) expect(unpublishedStampProblem(v, label, '17.4.0'), `${v} under ${label}`).toBeNull(); + for (const v of ['17.3.0', '18.0.0', '18.0.1']) expect(unpublishedStampProblem(v, label, '17.4.0'), `${v} under ${label}`).not.toBeNull(); + } + // Whatever the census holds, a prerelease label keeps the range open: it never takes the exact-label branch. + expect(unpublishedStampProblem('17.7.0', '18.0.0-next.0', '17.7.0')).toBeNull(); + expect(unpublishedStampProblem('18.0.0', '18.0.0-next.0', '18.0.0')).not.toBeNull(); + }); + + it('the same precedence bounds every comparison against the label', () => { + expect(atOrBelowLabel('17.7.0', '17.7.0')).toBe(true); + expect(atOrBelowLabel('17.7.1', '17.7.0')).toBe(false); + expect(atOrBelowLabel('17.7.0', '18.0.0-next.0')).toBe(true); + expect(atOrBelowLabel('18.0.0', '18.0.0-next.0')).toBe(false); + }); +}); + 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'); @@ -184,7 +260,7 @@ describe('the package label is read on 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. + // ...so its triple compares at, never above, the release it precedes (atOrBelowLabel adds the precedence). 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); }); diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index 383004851d8..acb6eca52df 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -264,11 +264,23 @@ interface RetiredConversionState { * retired entry, so tsc refuses a retirement that omits it. * * It is a FACT when the entry is written, never a guess at the next release - * number: it is the package's version label at the moment the retirement - * lands — `main` carries the last release's label until the next release is - * cut, and that release is the first one to refuse the old shape. For an - * entry already published, it is the stable release just before the first - * published tarball that carries the entry retired. + * number: the newest stable release at the moment the retirement lands, read + * off the package's version label. + * + * - Under a stable label (`x.y.z`), it is the label itself: `main` carries + * the last release's label until the next release is cut, and that release + * is the first one to refuse the old shape. + * - Under a prerelease label (`x.y.z-PRERELEASE`, Changesets pre mode), it is + * the newest published stable release, which sits below `x.y.z` — never the + * label (not a stable `x.y.z`) and never `x.y.z`, the first stable release + * that carries the entry retired (`17.7.0` under `18.0.0-next.N`, say). + * + * For an entry already published, it is the stable release just before the + * first published tarball that carries the entry retired. So a stable + * release published after the entry landed that does not carry the + * retirement — a `17.x` maintenance release during the 18 prerelease — still + * accepted the old shape and becomes the value: refreshing the census asks + * for that re-stamp. * * Read by the artifact-ingestion door (`applyArtifactForwardConversions`, * `@objectstack/metadata-core`): an artifact whose declared `engines.protocol`