Skip to content

Commit dc6c7ee

Browse files
fix(create-objectstack): template baseline pins @objectstack/* exactly; specVersion stays a range (#25665)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DT4vQy7pHzA3u25DpvdoWTR)_ Fixes #25510 Clause-②: no Metadata-protocol: none The remaining slice after PR #25539 (which made the scaffolders write exact pins). The committed template baseline now says what the scaffold writes, and the release-time version pass keeps it that way. ## What changed - **`scripts/sync-template-versions.mjs`** stamps two values where it used to stamp one: - every template `@objectstack/*` dependency gets the scaffolder's **exact** version (`18.1.0`; a prerelease such as `19.0.0-rc.0` verbatim); - the manifest's `specVersion` stays the major's **range** (`^18.0.0`), because it names the spec line the template is authored against. - `loadScaffolderVersion()` keeps its `{ version, major, range }` shape; `range` is documented as the `specVersion` value only. The version read is now anchored at both ends (`EXACT_VERSION`), since it is written verbatim into dependencies: `18.1.0garbage` used to pass a prefix match. - Header section rewritten from "two values" to "three values", stating why deps are exact and `specVersion` is not. - **`--self-test`**: fixtures are built in lockstep at the exact version. Two new batteries (roster floor 8 → 10): - Control I: a prerelease corpus (`42.0.0-rc.1`) is judged clean at its exact version with `specVersion` still `^42.0.0`, byte-identical and unwritten; - Control J: a scaffolder version that is not a whole semver exits 1 naming it, with no template surface rewritten. - **`template-consistency.test.ts`**: the ratchet now requires each `@objectstack/*` dep to equal `create-objectstack`'s exact version (was `^MAJOR.x`). The `specVersion` agreement test compares `specVersion` with `^<major of the pinned spec dep>.0.0` instead of string equality. - **`template-version-stamps.test.ts`**: the stale-to-rewritten run expects exact deps; a new case runs the CLI at `43.0.0-rc.2` and asserts the deps take it verbatim while `specVersion` and `engines.protocol` take the major. `copyWithLocalImports` gained a target-directory parameter so the second fixture copies the import closure instead of a hand list. - **`templates/blank/package.json`**: restamped by running the script itself (9 entries, `^18.0.0` → `18.1.0`). - Stale caret comments corrected in `create-objectstack/src/index.ts` (3 places) and `src/runtime-image.ts`. - Changeset: `create-objectstack` patch. ## Post-version trees Simulated by setting `create-objectstack`'s version, running the script, then the two ratchet files: | version | script output | ratchets | |:--|:--|:--| | `18.1.1` (minor/patch train) | deps at 18.1.1, specVersion at ^18.0.0 | 50/50 passed | | `19.0.0-rc.0` (major rc cut) | deps at 19.0.0-rc.0, specVersion at ^19.0.0, engines.protocol ^19 | 50/50 passed | `cut-rc.yml` and `release.yml` read the stamped surface from `stampedPaths()`, which is unchanged (same three files per template), and `cut-rc.yml`'s allowlist already admits each template's `package.json`. One behaviour change on the release path: on a minor or patch train the template `package.json` now changes on every version pass (it used to log "already pins"). That file is already in the allowlist, so the cut still pushes. ## Verification (head 3f96816) - `pnpm --filter create-objectstack test`: Test Files 18 passed (18), Tests 269 passed (269). - `pnpm --filter create-objectstack typecheck`: exit 0, after `pnpm --filter 'create-objectstack^...' build` exit 0. - `node scripts/sync-template-versions.mjs --self-test`: exit 0. Ablations, each restored after: deps stamped with the range again → 10 failures (Control A and I); the version regex back to a prefix match → Control J red; `specVersion` stamped from the exact version → Control A red on both manifests. - vitest ablations: the old `^18.0.0` baseline → 2 failures in `template-consistency.test.ts`; deps stamped with the range → 2 failures in `template-version-stamps.test.ts`. - `node scripts/sync-scaffold-emission-policy.mjs --self-test` (imports this script's declarations) exit 0, and `--check` exit 0. - Derived gates (`dispatch-gates --commands`): 100 families, 95 exit 0, 5 NOT MEASURED (exit 3): `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content` need a full build; `check-plugin-teardown-shape --self-test` needs an unshallow clone. `--ran` reconcile: "100 derived famil(ies) accounted for — 95 run, 5 NOT-MEASURED". CI runs all five. ## Acceptance notes - Docs half (`content/docs/protocol/kernel/plugin-spec.mdx`, `content/docs/upgrading.mdx`) rides the devx seat's own docs PR, which carries `Part of #25510`. - `skills/objectstack-platform/references/bootstrap.md:147-153` still shows `^17.0.0` for five `@objectstack/*` packages in a published skill's `package.json` example. Governed surface, not edited here; for the devx seat to route. - Comment-only staleness left alone because the files sit outside this claim: `.github/workflows/cut-rc.yml:505-507` ("On a minor train it logs already pins and writes nothing"), `scripts/regen-artifacts.mjs:781` and `scripts/cross-package-test-inputs.mjs:1522` (call the stamped values "ranges"). --- _Generated by [Claude Code](https://claude.ai/code/session_01GcPmaQ9wUnUUM3ATKkiCes)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GcPmaQ9wUnUUM3ATKkiCes Co-authored-by: Claude <noreply@anthropic.com>
1 parent 702b44f commit dc6c7ee

7 files changed

Lines changed: 261 additions & 85 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'create-objectstack': patch
3+
---
4+
5+
fix(create-objectstack): the bundled blank template pins every `@objectstack/*` dependency at the exact release version
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `blank` template's committed `package.json` now lists each `@objectstack/*` package at the exact version of the `create-objectstack` release that ships it (`18.1.0`, a prerelease verbatim), instead of `^18.0.0`. A scaffolded project already received exact pins, because the scaffolder rewrites these entries at scaffold time; the template a reader opens in the tarball or on GitHub now says the same thing, which is what the v18 docs prescribe ("Pin exact versions").
11+
12+
The template manifest's `specVersion` stays a range (`^18.0.0`): it names the `@objectstack/spec` line the template is authored against, not a build. The release-time version pass keeps both values current — the exact version in the dependencies, the major's range in `specVersion`.
13+
14+
No action is needed in an existing project.

‎packages/create-objectstack/src/index.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@
3333
* validate` already omits the description line entirely when the field is
3434
* absent, so this is a clean landing rather than a placeholder.
3535
*
36-
* Then we run `<pm> install` — and only afterwards can the Dockerfile's runtime
37-
* image tag be pinned, because the template carries a caret range and the tag
38-
* has to name the @objectstack/cli version npm actually resolved (#9017). With
36+
* Then we run `<pm> install` — and only afterwards is the Dockerfile's runtime
37+
* image tag pinned, to the @objectstack/cli version npm actually resolved
38+
* (#9017). The generated package.json pins that version exactly, but the
39+
* installed copy is what builds the artifact, so it is the one read. With
3940
* `--skip-install` there is no resolved version, so the template keeps `latest`
4041
* and its comment keeps telling the reader to pin by hand — true in that path.
4142
*
@@ -173,10 +174,10 @@ function rewriteProjectIdentity(
173174
const templateNamespace = readTemplateNamespace(targetDir);
174175

175176
// package.json — set .name and pin @objectstack/* deps to this scaffolder's
176-
// own release line. All @objectstack packages (including create-objectstack)
177-
// version in lockstep, so `^<own version>` always resolves and always matches
177+
// own exact version. All @objectstack packages (including create-objectstack)
178+
// version in lockstep, so `<own version>` always resolves and always matches
178179
// the framework the docs describe. Without this, a template whose literal
179-
// ranges have gone stale scaffolds a project several majors behind the
180+
// pins have gone stale scaffolds a project several majors behind the
180181
// published framework (the `^6.0.0`-era templates installed 6.x while the
181182
// registry was at 14.x).
182183
const pkgPath = path.join(targetDir, 'package.json');
@@ -582,8 +583,9 @@ const program = new Command()
582583
}
583584

584585
// Pin the Dockerfile's runtime image to the CLI that will build this
585-
// project's artifact — knowable only now, because the template pins a
586-
// caret RANGE and npm has just resolved it (#9017). Skipped without an
586+
// project's artifact — the copy npm has just installed (#9017). The
587+
// generated package.json pins it exactly, but the install is what
588+
// builds the artifact, so the tag follows it. Skipped without an
587589
// install: with no node_modules there is no resolved version, and the
588590
// template's own comment then correctly tells the user to pin by hand.
589591
if (installed) {

‎packages/create-objectstack/src/runtime-image.ts‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@
1616
// comment and the published tag guidance.
1717
//
1818
// The tag is read from the INSTALLED cli — `node_modules/@objectstack/cli`'s
19-
// own version — and not from the generated `package.json`, because that file
20-
// carries a caret RANGE (`^17.0.0`, written by syncObjectStackDeps). The two
21-
// are not interchangeable:
22-
//
23-
// - npm resolves `^17.0.0` to the newest 17.x, so the artifact is built by
24-
// (say) 17.0.5 while the range's floor is 17.0.0. Pinning the floor would
25-
// ship a runtime image OLDER than the CLI that built the artifact — the
26-
// exact promise the comment makes, broken in a new way.
27-
// - The rolling `:17` tag does match the range's float window, but it is a
28-
// rolling tag, which is what `docker/README.md` tells production not to use.
19+
// own version — and not from the generated `package.json`. That file now pins
20+
// the exact version (written by syncObjectStackDeps), so on a clean install the
21+
// two agree; the installed copy is still the one that builds the artifact, and
22+
// it is the only value that stays true when they do not (an override, a hand
23+
// edit, a package manager that resolved something else). The rolling `:17` tag
24+
// is never the answer: it is a rolling tag, which is what `docker/README.md`
25+
// tells production not to use.
2926
//
3027
// The resolved version is the only value that makes the sentence true, and it
3128
// is the same rule the repo already applies for the same reason in
@@ -41,7 +38,7 @@
4138
// and correct either way — only the tag is less precise — so aborting a
4239
// finished scaffold would be disproportionate. The real guard against a silent
4340
// no-op is `runtime-image.test.ts`, which asserts on SCAFFOLDED OUTPUT: the
44-
// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s range.
41+
// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s pin.
4542
// A template edit that moves the anchor turns that test red in CI.
4643

4744
import fs from 'node:fs';

‎packages/create-objectstack/src/template-consistency.test.ts‎

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = gitFreeEnv();
8989
//
9090
// Every bundled template declares the platform it targets in THREE places, and
9191
// each one is committed to git, shipped in the tarball and copied into every
92-
// scaffolded project. `scripts/sync-template-versions.mjs` re-stamps all three
92+
// scaffolded project: the `@objectstack/*` dependencies at the scaffolder's
93+
// EXACT version, `specVersion` at its major's range, and `engines.protocol` at
94+
// its protocol major. `scripts/sync-template-versions.mjs` re-stamps all three
9395
// at version time; these ratchets are the CI half, because that script runs on
9496
// a changesets/action release PR that gets no CI at all.
9597
//
@@ -121,22 +123,25 @@ describe('bundled template declared version surfaces', () => {
121123
const readTemplateFile = (name: string) =>
122124
fs.readFileSync(path.join(templateDir, name), 'utf8');
123125

124-
it('package.json pins every @objectstack/* dep to the current major', () => {
126+
// Exact, not a range: on the v18 line a breaking change may ship in a minor
127+
// or a patch, the docs tell a project to pin exact versions, and the
128+
// scaffold writes exact pins (`syncObjectStackDeps`). The committed
129+
// baseline says the same thing the scaffold writes.
130+
it("package.json pins every @objectstack/* dep to create-objectstack's exact version", () => {
125131
const templatePkg = JSON.parse(readTemplateFile('package.json'));
126132
const allDeps = { ...templatePkg.dependencies, ...templatePkg.devDependencies };
127133
const stackDeps = Object.entries(allDeps).filter(([name]) =>
128134
name.startsWith('@objectstack/'),
129135
);
130136
expect(stackDeps.length).toBeGreaterThan(0);
131-
for (const [name, range] of stackDeps) {
132-
const match = /^\^(\d+)\./.exec(String(range));
133-
expect(match, `${name} range "${range}" must be ^<major>.x`).not.toBeNull();
137+
for (const [name, pin] of stackDeps) {
134138
expect(
135-
Number(match![1]),
136-
`${name} pins ^${match![1]}.x but create-objectstack is v${ownMajor} — ` +
137-
'bump the template with the release (scaffold-time sync only fixes ' +
138-
'generated projects, not this committed baseline)',
139-
).toBe(ownMajor);
139+
pin,
140+
`${template}/package.json pins ${name} at "${String(pin)}" but create-objectstack is ` +
141+
`${ownPkg.version} — every @objectstack/* dep is the exact version, no ^ or ~; ` +
142+
'scripts/sync-template-versions.mjs re-stamps it at version time (scaffold-time ' +
143+
'sync only fixes generated projects, not this committed baseline)',
144+
).toBe(ownPkg.version);
140145
}
141146
});
142147

@@ -296,12 +301,14 @@ describe('bundled template declared version surfaces', () => {
296301
).toBe(ownMajor);
297302
});
298303

299-
// The invariant that makes the two files one fact rather than two: the
300-
// manifest's declared spec range and the dependency a scaffolded project
301-
// actually installs must agree. Either alone can be self-consistently
302-
// stale; only comparing them catches a stamp that covered one and not the
303-
// other, which is the exact failure this card is about.
304-
it('specVersion agrees with the @objectstack/spec dependency the template installs', () => {
304+
// The invariant that ties the two files together: the manifest's declared
305+
// spec range and the dependency a scaffolded project actually installs
306+
// must name the same line. Either alone can be self-consistently stale;
307+
// only comparing them catches a stamp that covered one and not the other,
308+
// which is the exact failure this card is about. They are no longer EQUAL
309+
// — the dependency is an exact version, `specVersion` a range — so what is
310+
// compared is the range the exact dependency's major implies.
311+
it('specVersion is the range of the @objectstack/spec dependency the template installs', () => {
305312
const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json'));
306313
const templatePkg = JSON.parse(readTemplateFile('package.json'));
307314
const specDep =
@@ -313,11 +320,16 @@ describe('bundled template declared version surfaces', () => {
313320
`${template}/package.json must depend on @objectstack/spec for its manifest's ` +
314321
'specVersion to be checkable against something',
315322
).toBeDefined();
323+
const depMajor = /^(\d+)\.\d+\.\d+/.exec(String(specDep));
324+
expect(
325+
depMajor,
326+
`${template}/package.json pins @objectstack/spec at "${String(specDep)}", which is not an exact version`,
327+
).not.toBeNull();
316328
expect(
317329
manifest.specVersion,
318330
`${template} declares specVersion "${manifest.specVersion}" but installs ` +
319-
`@objectstack/spec "${specDep}" — one fact written twice, and they disagree`,
320-
).toBe(specDep);
331+
`@objectstack/spec "${String(specDep)}" — the range must be ^${depMajor![1]}.0.0, the line the pin is on`,
332+
).toBe(`^${depMajor![1]}.0.0`);
321333
});
322334
});
323335
});

‎packages/create-objectstack/src/template-version-stamps.test.ts‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const writeFixtureFile = (file: string, content: string) => {
107107
* resolve its repo root to the real checkout instead of this fixture — which is
108108
* the very thing the copy exists to prevent.
109109
*/
110-
const copyWithLocalImports = (absFile: string, seen = new Set<string>()): void => {
110+
const copyWithLocalImports = (absFile: string, seen = new Set<string>(), into = fixture): void => {
111111
if (!fs.existsSync(absFile)) return;
112112
const real = fs.realpathSync(absFile);
113113
if (seen.has(real)) return;
@@ -118,13 +118,13 @@ const copyWithLocalImports = (absFile: string, seen = new Set<string>()): void =
118118
if (rel.startsWith('..') || path.isAbsolute(rel)) return;
119119

120120
const source = fs.readFileSync(real, 'utf8');
121-
writeFixtureFile(path.join(fixture, rel), source);
121+
writeFixtureFile(path.join(into, rel), source);
122122

123123
const specifiers = [
124124
...source.matchAll(/\bfrom\s+'(\.[^']*)'/g),
125125
...source.matchAll(/\bimport\s*\(\s*'(\.[^']*)'/g),
126126
];
127-
for (const m of specifiers) copyWithLocalImports(path.resolve(path.dirname(real), m[1]), seen);
127+
for (const m of specifiers) copyWithLocalImports(path.resolve(path.dirname(real), m[1]), seen, into);
128128
};
129129

130130
beforeAll(() => {
@@ -241,9 +241,9 @@ describe('the entry-point guard leaves the CLI path working', () => {
241241
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
242242
expect(
243243
pkg.dependencies['@objectstack/spec'],
244-
`${template}/package.json @objectstack/* ranges move to the scaffolder's major`,
245-
).toBe('^42.0.0');
246-
expect(pkg.devDependencies['@objectstack/cli']).toBe('^42.0.0');
244+
`${template}/package.json @objectstack/* deps move to the scaffolder's EXACT version`,
245+
).toBe(FIXTURE_VERSION);
246+
expect(pkg.devDependencies['@objectstack/cli']).toBe(FIXTURE_VERSION);
247247
expect(
248248
pkg.dependencies.chalk,
249249
'a non-@objectstack dependency is never touched',
@@ -254,7 +254,10 @@ describe('the entry-point guard leaves the CLI path working', () => {
254254
);
255255

256256
const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8');
257-
expect(manifest).toContain('"specVersion": "^42.0.0"');
257+
expect(
258+
manifest,
259+
'specVersion stays a RANGE while the deps beside it become exact',
260+
).toContain('"specVersion": "^42.0.0"');
258261
expect(
259262
manifest,
260263
'the manifest is rewritten as TEXT, so unrelated compact structure survives',
@@ -266,6 +269,53 @@ describe('the entry-point guard leaves the CLI path working', () => {
266269
expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0');
267270
expect(stdout).toContain('second/objectstack.manifest.json');
268271
});
272+
273+
it('stamps a PRERELEASE verbatim into the deps, and only its major into specVersion', () => {
274+
// A release-candidate cut (`cut-rc.yml`) runs this same pass at a version
275+
// like `19.0.0-rc.0`. The deps take it verbatim, because a scaffold of the
276+
// rc must install the rc; `specVersion` keeps the major's range, because it
277+
// names the spec line the template is authored against, not a build.
278+
const prerelease = '43.0.0-rc.2';
279+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-rc-'));
280+
try {
281+
const script = path.join(dir, 'scripts', 'sync-template-versions.mjs');
282+
copyWithLocalImports(SYNC_SCRIPT, new Set(), dir);
283+
writeFixtureFile(
284+
path.join(dir, 'packages', 'create-objectstack', 'package.json'),
285+
JSON.stringify({ name: 'create-objectstack', version: prerelease }, null, 2) + '\n',
286+
);
287+
const templateDir = path.join(dir, 'packages', 'create-objectstack', 'src', 'templates', 'blank');
288+
writeFixtureFile(
289+
path.join(templateDir, 'package.json'),
290+
JSON.stringify(
291+
{ name: 'template-blank', dependencies: { '@objectstack/spec': `^${STALE_MAJOR}.0.0` } },
292+
null,
293+
2,
294+
) + '\n',
295+
);
296+
writeFixtureFile(
297+
path.join(templateDir, 'objectstack.config.ts'),
298+
`export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`,
299+
);
300+
writeFixtureFile(
301+
path.join(templateDir, 'objectstack.manifest.json'),
302+
`{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`,
303+
);
304+
305+
execFileSync(process.execPath, [script], { encoding: 'utf8' });
306+
307+
const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, 'package.json'), 'utf8'));
308+
expect(pkg.dependencies['@objectstack/spec']).toBe(prerelease);
309+
expect(fs.readFileSync(path.join(templateDir, 'objectstack.manifest.json'), 'utf8')).toContain(
310+
'"specVersion": "^43.0.0"',
311+
);
312+
expect(fs.readFileSync(path.join(templateDir, 'objectstack.config.ts'), 'utf8')).toContain(
313+
"engines: { protocol: '^43' }",
314+
);
315+
} finally {
316+
fs.rmSync(dir, { recursive: true, force: true });
317+
}
318+
});
269319
});
270320

271321
// ── stampedPaths() is derived from the walk, never a restated list ──────────

‎packages/create-objectstack/src/templates/blank/package.json‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@
1616
"test": "vitest run"
1717
},
1818
"dependencies": {
19-
"@objectstack/spec": "^18.0.0",
20-
"@objectstack/runtime": "^18.0.0",
21-
"@objectstack/plugin-hono-server": "^18.0.0",
22-
"@objectstack/connector-rest": "^18.0.0",
23-
"@objectstack/connector-openapi": "^18.0.0",
24-
"@objectstack/connector-mcp": "^18.0.0"
19+
"@objectstack/spec": "18.1.0",
20+
"@objectstack/runtime": "18.1.0",
21+
"@objectstack/plugin-hono-server": "18.1.0",
22+
"@objectstack/connector-rest": "18.1.0",
23+
"@objectstack/connector-openapi": "18.1.0",
24+
"@objectstack/connector-mcp": "18.1.0"
2525
},
2626
"devDependencies": {
27-
"@objectstack/cli": "^18.0.0",
28-
"@objectstack/skills": "^18.0.0",
29-
"@objectstack/verify": "^18.0.0",
27+
"@objectstack/cli": "18.1.0",
28+
"@objectstack/skills": "18.1.0",
29+
"@objectstack/verify": "18.1.0",
3030
"typescript": "^5.3.0",
3131
"vitest": "~4.1.12"
3232
},

0 commit comments

Comments
 (0)