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
64 changes: 64 additions & 0 deletions .changeset/template-spec-version-sync.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"create-objectstack": patch
---

fix(create-objectstack): the blank template's `specVersion` stops shipping eleven majors stale, and the version-time sync covers every declared surface on every template (#9264)

The one bundled template declared the platform it targets in **two** places that
disagreed by eleven majors:

| file | key | was |
|:--|:--|:--|
| `objectstack.manifest.json` | `specVersion` | `^6.0.0` |
| `objectstack.config.ts` | `engines.protocol` | `^17` |

`scripts/sync-template-versions.mjs` re-stamped the config key and the template's
`@objectstack/*` dependency ranges, and **never opened the manifest at all**. So
`engines.protocol` tracked every major bump while `specVersion` sat at the value
it held when the script was written — and a green `sync-template-versions` run
was never evidence about it, because the script's failure mode was loud for the
keys it covered and mute for the key it did not.

**This is not confined to the registry contract.** `create-objectstack` copies
the manifest into every scaffolded project, rewriting `name`, `displayName` and
`namespace` and dropping `description` — it has never touched `specVersion`. So
every project scaffolded since v7 was stamped with a `^6.0.0` spec range while
installing `@objectstack/spec@^17.0.0`.

**The two keys are two facts, and the fix keeps them apart.** `engines.protocol`
is the ADR-0087 D1 runtime handshake range and carries the protocol major
(`^17`). `specVersion` is documented by `TemplateManifestSchema` as the
"Compatible `@objectstack/spec` semver range" and carries the package range
(`^17.0.0`) — the same value the script already writes into the template's own
`@objectstack/spec` dependency, so the manifest and the `package.json` now state
one fact once. They agree on the major only because the spec package's major and
the protocol major are kept in lockstep; they are stamped from two different
values.

Deleting the key was not available: `specVersion` is **required** by
`TemplateManifestSchema`, and every shipped manifest is parsed against it by
`check:template-manifests`.

**Two structural changes, because one-key-one-file coverage is what let this
sit:**

- the sync script's file list is now **discovered**, not hard-coded — templates
are found by walking `src/templates/`, the same way `check-template-manifests`
finds the manifests it parses, so a second template is covered on the day it
lands;
- **every stamp is required**. A template whose file is missing, whose stamp is
absent, or whose `package.json` declares no `@objectstack/*` dependency is a
hard failure naming the path — never a skip. A skipped stamp is
indistinguishable from a synced one in the log, which is the invisibility this
fixes.

The manifest is rewritten as **text** rather than parsed and re-serialized:
`objectstack.manifest.json` keeps `scaffold.variables` compact on one line, and
`JSON.stringify(…, null, 2)` would reformat unrelated structure on every release.

CI coverage lands as four per-template ratchets in `template-consistency.test.ts`,
generalized off `blank` onto the same directory walk — including the invariant
that catches this exact class: the manifest's `specVersion` must equal the
`@objectstack/spec` range the template actually installs. Either file alone can
be self-consistently stale; only comparing them catches a stamp that covered one
and not the other.
154 changes: 118 additions & 36 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,46 +72,128 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = (() => {
return env;
})();

describe('blank template package.json', () => {
const templatePkg = JSON.parse(
fs.readFileSync(
path.join(pkgRoot, 'src', 'templates', 'blank', 'package.json'),
'utf8',
),
);

it('pins every @objectstack/* dep to the current major', () => {
const allDeps = { ...templatePkg.dependencies, ...templatePkg.devDependencies };
const stackDeps = Object.entries(allDeps).filter(([name]) =>
name.startsWith('@objectstack/'),
);
expect(stackDeps.length).toBeGreaterThan(0);
for (const [name, range] of stackDeps) {
const match = /^\^(\d+)\./.exec(String(range));
expect(match, `${name} range "${range}" must be ^<major>.x`).not.toBeNull();
// ── Declared version surfaces, per bundled template (#9264) ─────────────────
//
// Every bundled template declares the platform it targets in THREE places, and
// each one is committed to git, shipped in the tarball and copied into every
// scaffolded project. `scripts/sync-template-versions.mjs` re-stamps all three
// at version time; these ratchets are the CI half, because that script runs on
// a changesets/action release PR that gets no CI at all.
//
// The template list is DISCOVERED, not written down — the same directory walk
// the sync script and `check-template-manifests.ts` both use. A hand-kept list
// is precisely what failed here: coverage of one key in one file is how
// `specVersion` sat at `^6.0.0` while `engines.protocol` tracked every major up
// to `^17`, eleven majors of drift behind a green sync run.
const TEMPLATES_DIR = path.join(pkgRoot, 'src', 'templates');
const bundledTemplates = fs
.readdirSync(TEMPLATES_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name !== 'node_modules' && e.name !== 'dist')
.map((e) => e.name)
.sort();

describe('bundled template declared version surfaces', () => {
// Vacuous-green guard: describe.each over an empty list is a silent pass, and
// "the templates directory moved" must not read as "every template is clean".
it('discovers at least one bundled template', () => {
expect(
bundledTemplates.length,
`no template directories under ${path.relative(pkgRoot, TEMPLATES_DIR)} — ` +
'the per-template ratchets below would all pass vacuously',
).toBeGreaterThan(0);
});

describe.each(bundledTemplates)('%s', (template) => {
const templateDir = path.join(TEMPLATES_DIR, template);
const readTemplateFile = (name: string) =>
fs.readFileSync(path.join(templateDir, name), 'utf8');

it('package.json pins every @objectstack/* dep to the current major', () => {
const templatePkg = JSON.parse(readTemplateFile('package.json'));
const allDeps = { ...templatePkg.dependencies, ...templatePkg.devDependencies };
const stackDeps = Object.entries(allDeps).filter(([name]) =>
name.startsWith('@objectstack/'),
);
expect(stackDeps.length).toBeGreaterThan(0);
for (const [name, range] of stackDeps) {
const match = /^\^(\d+)\./.exec(String(range));
expect(match, `${name} range "${range}" must be ^<major>.x`).not.toBeNull();
expect(
Number(match![1]),
`${name} pins ^${match![1]}.x but create-objectstack is v${ownMajor} — ` +
'bump the template with the release (scaffold-time sync only fixes ' +
'generated projects, not this committed baseline)',
).toBe(ownMajor);
}
});

// NOTE the file: this stamp lives in `objectstack.config.ts`, inside the
// `defineStack({ manifest: … })` literal. It is NOT in
// `objectstack.manifest.json` — the two were conflated in this suite's own
// naming and in the sync script's log strings, and that conflation is part
// of how the sibling key below went unwatched for eleven majors.
it("objectstack.config.ts stamps engines.protocol at the scaffolder's major (ADR-0087 D1)", () => {
const config = readTemplateFile('objectstack.config.ts');
const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config);
expect(
match,
`${template}/objectstack.config.ts must stamp engines.protocol (ADR-0087 D1)`,
).not.toBeNull();
expect(
Number(match![1]),
`${name} pins ^${match![1]}.x but create-objectstack is v${ownMajor} — ` +
'bump the template with the release (scaffold-time sync only fixes ' +
'generated projects, not this committed baseline)',
`${template} stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` +
'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep',
).toBe(ownMajor);
}
});
});
});

describe('blank template manifest engines.protocol (ADR-0087 D1)', () => {
it('stamps the current protocol major so the handshake covers fresh scaffolds', () => {
const config = fs.readFileSync(
path.join(pkgRoot, 'src', 'templates', 'blank', 'objectstack.config.ts'),
'utf8',
);
const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config);
expect(match, 'template manifest must stamp engines.protocol (ADR-0087 D1)').not.toBeNull();
expect(
Number(match![1]),
`template stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` +
'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep',
).toBe(ownMajor);
// The key #9264 is about. Required by TemplateManifestSchema, read by the
// template registry, and copied verbatim into every scaffolded project —
// `create-objectstack` rewrites name/displayName/namespace and drops
// description, and has never touched this one.
it('objectstack.manifest.json declares specVersion at the current @objectstack/spec range', () => {
const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json'));
expect(
typeof manifest.specVersion,
`${template}/objectstack.manifest.json must declare specVersion — it is REQUIRED by ` +
'TemplateManifestSchema (packages/spec/src/cloud/template-manifest.zod.ts)',
).toBe('string');

const match = /^\^(\d+)\.\d+\.\d+$/.exec(manifest.specVersion);
expect(
match,
`specVersion "${manifest.specVersion}" must be a ^<major>.0.0 package range — it is the ` +
'compatible @objectstack/spec range, not the protocol major that engines.protocol carries',
).not.toBeNull();
expect(
Number(match![1]),
`${template} declares specVersion "${manifest.specVersion}" but create-objectstack is ` +
`v${ownMajor} — scripts/sync-template-versions.mjs re-stamps this at version time`,
).toBe(ownMajor);
});

// The invariant that makes the two files one fact rather than two: the
// manifest's declared spec range and the dependency a scaffolded project
// actually installs must agree. Either alone can be self-consistently
// stale; only comparing them catches a stamp that covered one and not the
// other, which is the exact failure this card is about.
it('specVersion agrees with the @objectstack/spec dependency the template installs', () => {
const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json'));
const templatePkg = JSON.parse(readTemplateFile('package.json'));
const specDep =
templatePkg.dependencies?.['@objectstack/spec'] ??
templatePkg.devDependencies?.['@objectstack/spec'];

expect(
specDep,
`${template}/package.json must depend on @objectstack/spec for its manifest's ` +
'specVersion to be checkable against something',
).toBeDefined();
expect(
manifest.specVersion,
`${template} declares specVersion "${manifest.specVersion}" but installs ` +
`@objectstack/spec "${specDep}" — one fact written twice, and they disagree`,
).toBe(specDep);
});
});
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"$schema": "https://schemas.objectstack.dev/template-manifest.json",
"name": "blank",
"namespace": "blank",
"specVersion": "^6.0.0",
"specVersion": "^17.0.0",
"displayName": "Blank Starter",
"description": "Minimal ObjectStack environment with a single object — a clean slate for building.",
"category": "starter",
Expand Down
12 changes: 11 additions & 1 deletion scripts/check-cross-package-test-inputs.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,17 @@ const CROSS_PACKAGE_TEST_INPUTS = {
'create-objectstack': {
// src/template-consistency.test.ts reads doc frontmatter by repo-relative
// path to decide which templates are internal.
globs: ['content/**'],
//
// `sync-template-versions.mjs` is named in a comment rather than read, the
// same shape as `check-nul-bytes.mjs` above and settled the same way: a
// mention forces a declaration, and declaring the file is cheaper than
// rewording prose to dodge the scanner. Here the coupling is real on top of
// being cheap — that script STAMPS the three per-template version surfaces
// (`package.json` @objectstack/* ranges, `objectstack.config.ts`
// `engines.protocol`, `objectstack.manifest.json` `specVersion`) that the
// ratchets in that test assert, so a change to the stamper is exactly the
// change those ratchets exist to catch (#9264).
globs: ['content/**', 'scripts/sync-template-versions.mjs'],
},
};

Expand Down
Loading
Loading