Skip to content

Commit 3f96816

Browse files
committed
fix(create-objectstack): template baseline pins @objectstack/* exactly; specVersion stays a range
scripts/sync-template-versions.mjs now stamps the scaffolder's exact version (prereleases verbatim) into every bundled template's @objectstack/* dependencies, and keeps the manifest's specVersion at the major's range. The two values were one before, so the ratchet compared them for equality; it now holds the deps at the exact version and specVersion at the range the pinned spec dependency's major implies. The self-test gains two batteries: a prerelease corpus judged clean at its exact version with specVersion still a range, and a scaffolder version that is not a whole semver refused with nothing written. The version read is anchored at both ends, since it is now written verbatim. The blank template is restamped by the script itself, and the stale caret comments in index.ts and runtime-image.ts are corrected. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcPmaQ9wUnUUM3ATKkiCes
1 parent 49653f9 commit 3f96816

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)