Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/23042-retired-after-prerelease-stamp.md
Original file line number Diff line number Diff line change
@@ -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.
134 changes: 105 additions & 29 deletions packages/spec/src/conversions/retired-after.census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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);
}
});

Expand All @@ -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');
Expand All @@ -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);
});
Expand Down
22 changes: 17 additions & 5 deletions packages/spec/src/conversions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading