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
14 changes: 14 additions & 0 deletions .changeset/25510-template-baseline-exact-pins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'create-objectstack': patch
---

fix(create-objectstack): the bundled blank template pins every `@objectstack/*` dependency at the exact release version

Clause-②: no
Metadata-protocol: none

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").

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`.

No action is needed in an existing project.
18 changes: 10 additions & 8 deletions packages/create-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@
* validate` already omits the description line entirely when the field is
* absent, so this is a clean landing rather than a placeholder.
*
* Then we run `<pm> install` — and only afterwards can the Dockerfile's runtime
* image tag be pinned, because the template carries a caret range and the tag
* has to name the @objectstack/cli version npm actually resolved (#9017). With
* Then we run `<pm> install` — and only afterwards is the Dockerfile's runtime
* image tag pinned, to the @objectstack/cli version npm actually resolved
* (#9017). The generated package.json pins that version exactly, but the
* installed copy is what builds the artifact, so it is the one read. With
* `--skip-install` there is no resolved version, so the template keeps `latest`
* and its comment keeps telling the reader to pin by hand — true in that path.
*
Expand Down Expand Up @@ -173,10 +174,10 @@ function rewriteProjectIdentity(
const templateNamespace = readTemplateNamespace(targetDir);

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

// Pin the Dockerfile's runtime image to the CLI that will build this
// project's artifact — knowable only now, because the template pins a
// caret RANGE and npm has just resolved it (#9017). Skipped without an
// project's artifact — the copy npm has just installed (#9017). The
// generated package.json pins it exactly, but the install is what
// builds the artifact, so the tag follows it. Skipped without an
// install: with no node_modules there is no resolved version, and the
// template's own comment then correctly tells the user to pin by hand.
if (installed) {
Expand Down
19 changes: 8 additions & 11 deletions packages/create-objectstack/src/runtime-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@
// comment and the published tag guidance.
//
// The tag is read from the INSTALLED cli — `node_modules/@objectstack/cli`'s
// own version — and not from the generated `package.json`, because that file
// carries a caret RANGE (`^17.0.0`, written by syncObjectStackDeps). The two
// are not interchangeable:
//
// - npm resolves `^17.0.0` to the newest 17.x, so the artifact is built by
// (say) 17.0.5 while the range's floor is 17.0.0. Pinning the floor would
// ship a runtime image OLDER than the CLI that built the artifact — the
// exact promise the comment makes, broken in a new way.
// - The rolling `:17` tag does match the range's float window, but it is a
// rolling tag, which is what `docker/README.md` tells production not to use.
// own version — and not from the generated `package.json`. That file now pins
// the exact version (written by syncObjectStackDeps), so on a clean install the
// two agree; the installed copy is still the one that builds the artifact, and
// it is the only value that stays true when they do not (an override, a hand
// edit, a package manager that resolved something else). The rolling `:17` tag
// is never the answer: it is a rolling tag, which is what `docker/README.md`
// tells production not to use.
//
// The resolved version is the only value that makes the sentence true, and it
// is the same rule the repo already applies for the same reason in
Expand All @@ -41,7 +38,7 @@
// and correct either way — only the tag is less precise — so aborting a
// finished scaffold would be disproportionate. The real guard against a silent
// no-op is `runtime-image.test.ts`, which asserts on SCAFFOLDED OUTPUT: the
// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s range.
// emitted Dockerfile's `FROM` tag against the emitted `package.json`'s pin.
// A template edit that moves the anchor turns that test red in CI.

import fs from 'node:fs';
Expand Down
48 changes: 30 additions & 18 deletions packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ const REPO_READ_ENV: NodeJS.ProcessEnv = gitFreeEnv();
//
// 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
// scaffolded project: the `@objectstack/*` dependencies at the scaffolder's
// EXACT version, `specVersion` at its major's range, and `engines.protocol` at
// its protocol major. `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.
//
Expand Down Expand Up @@ -121,22 +123,25 @@ describe('bundled template declared version surfaces', () => {
const readTemplateFile = (name: string) =>
fs.readFileSync(path.join(templateDir, name), 'utf8');

it('package.json pins every @objectstack/* dep to the current major', () => {
// Exact, not a range: on the v18 line a breaking change may ship in a minor
// or a patch, the docs tell a project to pin exact versions, and the
// scaffold writes exact pins (`syncObjectStackDeps`). The committed
// baseline says the same thing the scaffold writes.
it("package.json pins every @objectstack/* dep to create-objectstack's exact version", () => {
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();
for (const [name, pin] of stackDeps) {
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);
pin,
`${template}/package.json pins ${name} at "${String(pin)}" but create-objectstack is ` +
`${ownPkg.version} — every @objectstack/* dep is the exact version, no ^ or ~; ` +
'scripts/sync-template-versions.mjs re-stamps it at version time (scaffold-time ' +
'sync only fixes generated projects, not this committed baseline)',
).toBe(ownPkg.version);
}
});

Expand Down Expand Up @@ -296,12 +301,14 @@ describe('bundled template declared version surfaces', () => {
).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', () => {
// The invariant that ties the two files together: the manifest's declared
// spec range and the dependency a scaffolded project actually installs
// must name the same line. 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. They are no longer EQUAL
// — the dependency is an exact version, `specVersion` a range — so what is
// compared is the range the exact dependency's major implies.
it('specVersion is the range of the @objectstack/spec dependency the template installs', () => {
const manifest = JSON.parse(readTemplateFile('objectstack.manifest.json'));
const templatePkg = JSON.parse(readTemplateFile('package.json'));
const specDep =
Expand All @@ -313,11 +320,16 @@ describe('bundled template declared version surfaces', () => {
`${template}/package.json must depend on @objectstack/spec for its manifest's ` +
'specVersion to be checkable against something',
).toBeDefined();
const depMajor = /^(\d+)\.\d+\.\d+/.exec(String(specDep));
expect(
depMajor,
`${template}/package.json pins @objectstack/spec at "${String(specDep)}", which is not an exact version`,
).not.toBeNull();
expect(
manifest.specVersion,
`${template} declares specVersion "${manifest.specVersion}" but installs ` +
`@objectstack/spec "${specDep}" — one fact written twice, and they disagree`,
).toBe(specDep);
`@objectstack/spec "${String(specDep)}" — the range must be ^${depMajor![1]}.0.0, the line the pin is on`,
).toBe(`^${depMajor![1]}.0.0`);
});
});
});
Expand Down
64 changes: 57 additions & 7 deletions packages/create-objectstack/src/template-version-stamps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const writeFixtureFile = (file: string, content: string) => {
* resolve its repo root to the real checkout instead of this fixture — which is
* the very thing the copy exists to prevent.
*/
const copyWithLocalImports = (absFile: string, seen = new Set<string>()): void => {
const copyWithLocalImports = (absFile: string, seen = new Set<string>(), into = fixture): void => {
if (!fs.existsSync(absFile)) return;
const real = fs.realpathSync(absFile);
if (seen.has(real)) return;
Expand All @@ -118,13 +118,13 @@ const copyWithLocalImports = (absFile: string, seen = new Set<string>()): void =
if (rel.startsWith('..') || path.isAbsolute(rel)) return;

const source = fs.readFileSync(real, 'utf8');
writeFixtureFile(path.join(fixture, rel), source);
writeFixtureFile(path.join(into, rel), source);

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

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

const manifest = fs.readFileSync(path.join(dir, 'objectstack.manifest.json'), 'utf8');
expect(manifest).toContain('"specVersion": "^42.0.0"');
expect(
manifest,
'specVersion stays a RANGE while the deps beside it become exact',
).toContain('"specVersion": "^42.0.0"');
expect(
manifest,
'the manifest is rewritten as TEXT, so unrelated compact structure survives',
Expand All @@ -266,6 +269,53 @@ describe('the entry-point guard leaves the CLI path working', () => {
expect(stdout).toContain('2 template(s) in lockstep with create-objectstack@42.0.0');
expect(stdout).toContain('second/objectstack.manifest.json');
});

it('stamps a PRERELEASE verbatim into the deps, and only its major into specVersion', () => {
// A release-candidate cut (`cut-rc.yml`) runs this same pass at a version
// like `19.0.0-rc.0`. The deps take it verbatim, because a scaffold of the
// rc must install the rc; `specVersion` keeps the major's range, because it
// names the spec line the template is authored against, not a build.
const prerelease = '43.0.0-rc.2';
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-template-versions-rc-'));
try {
const script = path.join(dir, 'scripts', 'sync-template-versions.mjs');
copyWithLocalImports(SYNC_SCRIPT, new Set(), dir);
writeFixtureFile(
path.join(dir, 'packages', 'create-objectstack', 'package.json'),
JSON.stringify({ name: 'create-objectstack', version: prerelease }, null, 2) + '\n',
);
const templateDir = path.join(dir, 'packages', 'create-objectstack', 'src', 'templates', 'blank');
writeFixtureFile(
path.join(templateDir, 'package.json'),
JSON.stringify(
{ name: 'template-blank', dependencies: { '@objectstack/spec': `^${STALE_MAJOR}.0.0` } },
null,
2,
) + '\n',
);
writeFixtureFile(
path.join(templateDir, 'objectstack.config.ts'),
`export default defineStack({ manifest: { engines: { protocol: '^${STALE_MAJOR}' } } });\n`,
);
writeFixtureFile(
path.join(templateDir, 'objectstack.manifest.json'),
`{\n "specVersion": "^${STALE_MAJOR}.0.0",\n "scaffold": { "variables": [] }\n}\n`,
);

execFileSync(process.execPath, [script], { encoding: 'utf8' });

const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, 'package.json'), 'utf8'));
expect(pkg.dependencies['@objectstack/spec']).toBe(prerelease);
expect(fs.readFileSync(path.join(templateDir, 'objectstack.manifest.json'), 'utf8')).toContain(
'"specVersion": "^43.0.0"',
);
expect(fs.readFileSync(path.join(templateDir, 'objectstack.config.ts'), 'utf8')).toContain(
"engines: { protocol: '^43' }",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

// ── stampedPaths() is derived from the walk, never a restated list ──────────
Expand Down
18 changes: 9 additions & 9 deletions packages/create-objectstack/src/templates/blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
"test": "vitest run"
},
"dependencies": {
"@objectstack/spec": "^18.0.0",
"@objectstack/runtime": "^18.0.0",
"@objectstack/plugin-hono-server": "^18.0.0",
"@objectstack/connector-rest": "^18.0.0",
"@objectstack/connector-openapi": "^18.0.0",
"@objectstack/connector-mcp": "^18.0.0"
"@objectstack/spec": "18.1.0",
"@objectstack/runtime": "18.1.0",
"@objectstack/plugin-hono-server": "18.1.0",
"@objectstack/connector-rest": "18.1.0",
"@objectstack/connector-openapi": "18.1.0",
"@objectstack/connector-mcp": "18.1.0"
},
"devDependencies": {
"@objectstack/cli": "^18.0.0",
"@objectstack/skills": "^18.0.0",
"@objectstack/verify": "^18.0.0",
"@objectstack/cli": "18.1.0",
"@objectstack/skills": "18.1.0",
"@objectstack/verify": "18.1.0",
"typescript": "^5.3.0",
"vitest": "~4.1.12"
},
Expand Down
Loading
Loading