From 4eb2fd323b3b89e368adeea952dd74dee68460b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:06:13 +0000 Subject: [PATCH 1/2] fix(create-objectstack): pin the scaffolded Dockerfile's runtime image to the CLI that builds the artifact (#9017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank template shipped `FROM ghcr.io/objectstack-ai/objectstack:latest` directly beneath a comment telling the reader to pin the tag to the `@objectstack/cli` version in their package.json — an instruction the scaffold itself did not follow, baked into every `npx create-objectstack` app. The tag is resolved AFTER install, from the installed CLI, not from the generated package.json: that file carries a caret range, and npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a runtime image older than the CLI that built the artifact. This is the rule scaffold-e2e.yml already applies for the same reason. Both halves move together — the comment above the FROM line is rewritten in the same pass, so the scaffold no longer instructs a step it just performed. With --skip-install there is no resolved version: the tag stays `latest` and the imperative comment stays, which is true on that path. scaffold-e2e.yml now reads the tag it builds its local runtime image under out of the generated Dockerfile instead of hardcoding `:latest`. Those were two hand-matched literals; a skew would have made Docker pull the last published image instead of the one built from this checkout, leaving the job's stated hermeticity false while it stayed green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- .changeset/scaffold-runtime-image-pinned.md | 49 ++++ .github/workflows/scaffold-e2e.yml | 21 +- packages/create-objectstack/src/index.ts | 36 ++- .../src/runtime-image.test.ts | 230 ++++++++++++++++++ .../create-objectstack/src/runtime-image.ts | 151 ++++++++++++ .../src/templates/blank/Dockerfile | 9 +- 6 files changed, 490 insertions(+), 6 deletions(-) create mode 100644 .changeset/scaffold-runtime-image-pinned.md create mode 100644 packages/create-objectstack/src/runtime-image.test.ts create mode 100644 packages/create-objectstack/src/runtime-image.ts diff --git a/.changeset/scaffold-runtime-image-pinned.md b/.changeset/scaffold-runtime-image-pinned.md new file mode 100644 index 0000000000..822ba6b863 --- /dev/null +++ b/.changeset/scaffold-runtime-image-pinned.md @@ -0,0 +1,49 @@ +--- +"create-objectstack": patch +--- + +fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017) + +`src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest` +directly beneath a comment instructing the reader to "pin the tag to the +`@objectstack/cli` version in your package.json so the runtime matches the CLI that built +the artifact" — an instruction the scaffold itself did not follow. Every app made with +`npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s +tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the +production pin. + +Measured on scaffolded output rather than the template's bytes, before the fix: + +``` +emitted package.json cli range : ^17.0.0 +emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest +agreement (tag vs cli range) : DISAGREE +``` + +**The tag is resolved after `install`, from the installed CLI — not from the generated +`package.json`.** That file carries a caret RANGE, and the two are not interchangeable: +npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a +runtime image *older* than the CLI that built the artifact — breaking the same promise in +a new way. The rolling `:17` tag does match the range's float window but is exactly what +the tag table tells production not to use. The resolved version is the only value that +makes the sentence true, and it is the rule the repo already applies for this purpose in +`.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the +generated project actually resolved to — NOT a hardcoded `latest`"). + +**Both halves move together.** Pinning the line while leaving an imperative to pin by hand +would relocate the contradiction rather than remove it, so the comment above the `FROM` +line is replaced in the same rewrite. With `--skip-install` there is no resolved version: +the tag stays `latest` and the comment keeps telling the reader to pin — which is true on +that path, because there the user really must do it by hand. + +The regression proof asserts on **scaffolded output**, never on the template: it scaffolds +with the real copy/sync/pin path, plants an installed CLI whose version is deliberately +*not* the range's floor (the normal case, and the one that a package.json-derived tag +would get wrong), and checks the emitted `FROM` tag against the emitted `package.json` +range with a satisfies-check rather than equality. + +`.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image +under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two +hand-matched literals; had they skewed, Docker would have quietly pulled the last +published image instead of the one built from this checkout, and the job's own stated +hermeticity would have been false while it stayed green. diff --git a/.github/workflows/scaffold-e2e.yml b/.github/workflows/scaffold-e2e.yml index 78956b9dc8..c2e546917e 100644 --- a/.github/workflows/scaffold-e2e.yml +++ b/.github/workflows/scaffold-e2e.yml @@ -215,10 +215,29 @@ jobs: # resolved version keeps the image in lockstep whether the project got # the repo RC, the `latest` fallback (see the install step), or a # published stable. + # + # The TAG this image is built under is READ OUT OF the generated + # Dockerfile rather than hardcoded (#9017). The two used to be + # hand-matched literals — `:latest` here and `:latest` in the template — + # and the scaffolder now pins that tag to the CLI a project resolved, so + # a hardcoded tag here would stop naming the image the scaffolded + # `FROM` asks for. Docker would then silently pull the last PUBLISHED + # image instead of the one built from this checkout, and (b) above would + # be quietly false while the job stayed green. Deriving both from one + # source is what makes that skew impossible rather than merely fixed. + # (This job scaffolds with --skip-install, so today that tag reads + # `latest`; it follows the file if that ever changes.) run: | CLI_VERSION=$(node -p "require('$RUNNER_TEMP/e2e-app/node_modules/@objectstack/cli/package.json').version") + RUNTIME_TAG=$(sed -n 's|^FROM ghcr\.io/objectstack-ai/objectstack:||p' \ + "$RUNNER_TEMP/e2e-app/Dockerfile") + if [ -z "$RUNTIME_TAG" ]; then + echo "::error::no FROM ghcr.io/objectstack-ai/objectstack: line in the scaffolded Dockerfile — the base image the next step builds against cannot be named, so this job would silently test a pulled image instead of this checkout" + exit 1 + fi echo "Runtime image will bundle @objectstack/cli@$CLI_VERSION (matches the scaffolded artifact's protocol)" - docker build -t ghcr.io/objectstack-ai/objectstack:latest \ + echo "Tagging it ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG (read from the scaffolded Dockerfile)" + docker build -t "ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG" \ --build-arg OS_CLI_VERSION="$CLI_VERSION" \ "$GITHUB_WORKSPACE/docker" diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index fa5488234e..de7f75557d 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -24,8 +24,14 @@ * - objectstack.config.ts manifest.id and manifest.name string literals * - README.md first H1 * - * Finally we run ` install` and (best-effort) install the ObjectStack - * skills bundle via `npx skills add objectstack-ai/objectstack/skills --all`. + * Then we run ` 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 + * `--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. + * + * Finally we (best-effort) install the ObjectStack skills bundle via + * `npx skills add objectstack-ai/objectstack/skills --all`. * The `/skills` subpath scopes discovery to the curated, customer-published * catalog — repo-internal skills (e.g. under `.claude/skills/`) must never * reach scaffolded projects. @@ -46,6 +52,7 @@ import { findStaleNamespacePrefixes, } from './rewrite-identity.js'; import { lookupTemplate, templateNames } from './template-registry.js'; +import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -348,14 +355,39 @@ const program = new Command() if (!options.skipInstall) { printStep('Installing dependencies...'); + let installed = false; try { const pm = detectPackageManager(); execSync(`${pm} install`, { stdio: 'inherit', cwd: targetDir }); + installed = true; console.log(''); } catch { printWarning('Dependency installation failed. Run `npm install` manually.'); console.log(''); } + + // 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 + // 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) { + const resolved = readResolvedCliVersion(targetDir); + if (resolved) { + const result = pinRuntimeImage(targetDir, resolved); + if (result.pinned) { + printSuccess(`Dockerfile runtime image pinned to ${result.tag}`); + } else { + // Not fatal: the project is complete, the tag is just less + // precise than it could be. runtime-image.test.ts is the guard. + printWarning( + `Could not pin the Dockerfile runtime image (${result.reason}); ` + + `it still reads \`latest\` — pin it to ${resolved} before deploying.`, + ); + } + console.log(''); + } + } } if (!options.skipInstall && !options.skipSkills) { diff --git a/packages/create-objectstack/src/runtime-image.test.ts b/packages/create-objectstack/src/runtime-image.test.ts new file mode 100644 index 0000000000..30a8092f96 --- /dev/null +++ b/packages/create-objectstack/src/runtime-image.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The gate for #9017. `dispatch-gates.mjs` reports "No check family names the +// given paths in its own source" for this surface, so the check farm will not +// catch a regression here — this file is the only thing that will. +// +// Every assertion below therefore runs against SCAFFOLDED OUTPUT, produced with +// the real `copyDir` + `syncObjectStackDeps` + `pinRuntimeImage`, never against +// the template's bytes. Grepping the template for a version string would prove +// nothing about what a user's generated app actually contains — the same class +// of mistake as asserting on a config object instead of what the client +// resolved. The defect being guarded is precisely a disagreement between two +// emitted files, so both have to be read out of a scaffold. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { copyDir } from './template-copy.js'; +import { syncObjectStackDeps } from './pkg-utils.js'; +import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const blankTemplate = path.join(pkgRoot, 'src', 'templates', 'blank'); +const ownVersion = JSON.parse( + fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'), +).version; + +/** The runtime tag of a scaffolded Dockerfile. */ +function runtimeTag(dir: string): string | undefined { + const line = fs + .readFileSync(path.join(dir, 'Dockerfile'), 'utf8') + .split('\n') + .find((l) => l.startsWith('FROM ghcr.io/objectstack-ai/objectstack:')); + return line?.split(':').pop()?.trim(); +} + +/** The `@objectstack/cli` range of a scaffolded package.json. */ +function cliRange(dir: string): string { + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + return pkg.devDependencies['@objectstack/cli']; +} + +/** + * Does `version` satisfy the caret `range` the scaffolder writes? Deliberately + * a satisfies-check and not an equality one: the emitted package.json carries + * `^X.Y.Z` and the tag names the version npm RESOLVED inside that range, so + * equality would be the wrong agreement predicate and would pass only by + * accident on a freshly published major. + */ +function satisfiesCaret(range: string, version: string): boolean { + const r = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range); + const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!r || !v) return false; + const [rMaj, rMin, rPat] = r.slice(1).map(Number); + const [vMaj, vMin, vPat] = v.slice(1).map(Number); + if (vMaj !== rMaj) return false; + if (vMin !== rMin) return vMin > rMin; + return vPat >= rPat; +} + +/** Scaffold the blank template the way index.ts does, up to the install. */ +function scaffold(dir: string): void { + copyDir(blankTemplate, dir, []); + const pkgPath = path.join(dir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.name = 'my-app'; + syncObjectStackDeps(pkg, ownVersion); + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +} + +/** Plant an installed `@objectstack/cli`, i.e. what `npm install` leaves. */ +function installCli(dir: string, version: string): void { + const cliDir = path.join(dir, 'node_modules', '@objectstack', 'cli'); + fs.mkdirSync(cliDir, { recursive: true }); + fs.writeFileSync( + path.join(cliDir, 'package.json'), + JSON.stringify({ name: '@objectstack/cli', version }) + '\n', + ); +} + +describe('scaffolded Dockerfile runtime image tag (#9017)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scaffold-runtime-image-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe('with dependencies installed (the default path)', () => { + // The version npm resolved is deliberately NOT the range's floor: that is + // the normal case (`^17.0.0` installs the newest 17.x) and it is the case + // that would go unnoticed if the tag were taken from package.json instead. + const resolved = `${String(ownVersion).split('.')[0]}.4.2`; + + beforeEach(() => { + scaffold(dir); + installCli(dir, resolved); + const result = pinRuntimeImage(dir, readResolvedCliVersion(dir)!); + expect(result.pinned, 'pinRuntimeImage refused the scaffolded Dockerfile').toBe(true); + }); + + it('pins the FROM tag to the @objectstack/cli the project resolved', () => { + expect(runtimeTag(dir)).toBe(resolved); + }); + + it('emits a tag that agrees with the emitted package.json range', () => { + const range = cliRange(dir); + const tag = runtimeTag(dir)!; + expect( + satisfiesCaret(range, tag), + `scaffolded Dockerfile pins :${tag} but the scaffolded package.json ` + + `asks for "${range}" — the generated app would run a runtime image ` + + 'that is not the CLI building its artifact (#9017)', + ).toBe(true); + }); + + it('never leaves the floating tag behind', () => { + expect(runtimeTag(dir)).not.toBe('latest'); + }); + + // The whole defect was a comment and a line disagreeing. Pinning the line + // while leaving an instruction to pin by hand would move the contradiction + // rather than remove it. + it('stops instructing a manual pin the scaffolder just performed', () => { + const dockerfile = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf8'); + expect(dockerfile).not.toMatch(/pin it to that version/i); + expect(dockerfile).not.toMatch(/could not be resolved for you/i); + expect(dockerfile).toMatch(/Pinned at scaffold time/); + }); + + it('leaves the rest of the Dockerfile intact', () => { + const dockerfile = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf8'); + expect(dockerfile).toContain('FROM node:22-slim AS build'); + expect(dockerfile).toContain( + 'COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json', + ); + expect(dockerfile).toContain('# ── Runtime: the official ObjectStack runtime image'); + // The build stage's FROM must not be touched by the runtime rewrite. + expect(dockerfile.match(/^FROM /gm)).toHaveLength(2); + }); + }); + + describe('with --skip-install (no resolved version to pin to)', () => { + beforeEach(() => { + scaffold(dir); + }); + + it('has no resolved CLI version to read', () => { + expect(readResolvedCliVersion(dir)).toBeUndefined(); + }); + + // Honest fallback: nothing was resolved, so nothing is pinned — and the + // comment's imperative is TRUE on this path, because the user really does + // have to pin by hand. + it('keeps `latest` and keeps telling the reader to pin it', () => { + expect(runtimeTag(dir)).toBe('latest'); + const dockerfile = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf8'); + expect(dockerfile).toMatch(/pin it to\s+# that version/s); + expect(dockerfile).not.toMatch(/Pinned at scaffold time/); + }); + + // The scaffold-e2e workflow scaffolds with --skip-install and builds the + // generated Dockerfile against a runtime image it tags with the tag it + // reads out of that very file. A tag it cannot parse would break that leg, + // so the unpinned shape has to stay machine-readable too. + it('leaves a FROM line the e2e can read a tag out of', () => { + const line = fs + .readFileSync(path.join(dir, 'Dockerfile'), 'utf8') + .split('\n') + .find((l) => l.startsWith('FROM ghcr.io/objectstack-ai/objectstack:')); + expect(line).toBeDefined(); + expect( + /^FROM ghcr\.io\/objectstack-ai\/objectstack:[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(line!), + ).toBe(true); + }); + }); + + describe('pinRuntimeImage refusals', () => { + it('refuses a non-version tag instead of writing garbage', () => { + scaffold(dir); + const result = pinRuntimeImage(dir, 'latest'); + expect(result.pinned).toBe(false); + expect(runtimeTag(dir)).toBe('latest'); + }); + + it('reports a missing Dockerfile rather than throwing', () => { + const result = pinRuntimeImage(dir, '17.0.0'); + expect(result.pinned).toBe(false); + expect(result.pinned === false && result.reason).toMatch(/no Dockerfile/); + }); + + // The anchor is the FROM line. If a future template edit removes or + // re-spells it, the pin must report that rather than silently no-op. + it('reports a missing anchor rather than silently doing nothing', () => { + scaffold(dir); + const dockerfile = path.join(dir, 'Dockerfile'); + fs.writeFileSync( + dockerfile, + fs + .readFileSync(dockerfile, 'utf8') + .replace(/^FROM ghcr\.io.*$/m, 'FROM some-other-registry/objectstack:latest'), + ); + const result = pinRuntimeImage(dir, '17.0.0'); + expect(result.pinned).toBe(false); + expect(result.pinned === false && result.reason).toMatch(/no `FROM ghcr\.io/); + }); + }); + + describe('readResolvedCliVersion', () => { + it('reads the installed CLI version', () => { + installCli(dir, '17.4.2'); + expect(readResolvedCliVersion(dir)).toBe('17.4.2'); + }); + + it('reads a prerelease, which ghcr publishes as its own exact tag', () => { + installCli(dir, '18.0.0-rc.3'); + expect(readResolvedCliVersion(dir)).toBe('18.0.0-rc.3'); + }); + + it('ignores a version it cannot turn into a tag', () => { + installCli(dir, 'workspace:*'); + expect(readResolvedCliVersion(dir)).toBeUndefined(); + }); + }); +}); diff --git a/packages/create-objectstack/src/runtime-image.ts b/packages/create-objectstack/src/runtime-image.ts new file mode 100644 index 0000000000..0c4f045e2b --- /dev/null +++ b/packages/create-objectstack/src/runtime-image.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Pins the scaffolded Dockerfile's runtime image tag. Kept out of index.ts +// because that module calls `program.parse()` on import — anything a test needs +// must be importable without running the CLI (same reason as pkg-utils.ts and +// rewrite-identity.ts). +// +// WHY THE TAG IS RESOLVED AFTER INSTALL, NOT FROM THE TEMPLATE +// ------------------------------------------------------------ +// The template shipped `FROM ghcr.io/objectstack-ai/objectstack:latest` under a +// comment telling the reader to "pin the tag to the @objectstack/cli version in +// your package.json" — an instruction the scaffold itself did not follow, baked +// into every app made with `npx create-objectstack` (#9017). `docker/README.md` +// scopes `latest` to quick starts and documents `X.Y.Z` as the production pin, +// so a production app template landing on `latest` contradicted both its own +// 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. +// +// 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 +// `.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME +// version the generated project actually resolved to — NOT a hardcoded +// `latest`"): during an RC window a fixed tag skews protocol majors and `os +// start` refuses to boot the artifact. +// +// WHY A FAILED PIN WARNS INSTEAD OF THROWING +// ------------------------------------------ +// `rewrite-identity.ts` throws when its rewrite silently does nothing, because +// there the generated project cannot build at all. Here the project is complete +// 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. +// A template edit that moves the anchor turns that test red in CI. + +import fs from 'node:fs'; +import path from 'node:path'; + +/** The runtime `FROM` line, with its tag captured. Docker tag charset. */ +const RUNTIME_FROM_RE = + /^FROM ghcr\.io\/objectstack-ai\/objectstack:([A-Za-z0-9_][A-Za-z0-9_.+-]*)[ \t]*$/; + +/** A version we are willing to write into a Dockerfile tag. */ +const PINNABLE_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +/** A comment line carrying prose (`# text`) — a bare `#` ends the paragraph. */ +const PROSE_COMMENT_RE = /^#[ \t]+\S/; + +export type PinResult = + | { pinned: true; tag: string } + | { pinned: false; reason: string }; + +/** + * The version of `@objectstack/cli` actually installed into a scaffolded + * project, i.e. the CLI that will build its artifact. Undefined when nothing is + * installed (`--skip-install`, or an install that failed), which is the one + * case where there is genuinely no version to pin to. + */ +export function readResolvedCliVersion(targetDir: string): string | undefined { + const pkgPath = path.join( + targetDir, + 'node_modules', + '@objectstack', + 'cli', + 'package.json', + ); + try { + const version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; + return typeof version === 'string' && PINNABLE_VERSION_RE.test(version) + ? version + : undefined; + } catch { + // not installed, or unreadable → no opinion + return undefined; + } +} + +/** The comment paragraph that replaces the "pin this yourself" instruction. */ +function pinnedComment(version: string): string[] { + return [ + '# Pinned at scaffold time to the @objectstack/cli version this project', + '# resolved, so the runtime runs the same CLI that built your artifact.', + '# Move both together when you upgrade — see docker/README.md tag table.', + `FROM ghcr.io/objectstack-ai/objectstack:${version}`, + ]; +} + +/** + * Rewrite the scaffolded Dockerfile's runtime tag to `version`, and replace the + * comment paragraph above it so it stops instructing a step the scaffolder has + * now performed. Both halves move together: leaving the imperative in place + * would relocate #9017's contradiction rather than remove it. + * + * The comment paragraph is found by walking up from the `FROM` line over + * contiguous prose comments — anchored on the line being rewritten, so there is + * only one assumption about the template's shape, not two. + */ +export function pinRuntimeImage(targetDir: string, version: string): PinResult { + if (!PINNABLE_VERSION_RE.test(version)) { + return { pinned: false, reason: `'${version}' is not a pinnable version` }; + } + + const dockerfile = path.join(targetDir, 'Dockerfile'); + let text: string; + try { + text = fs.readFileSync(dockerfile, 'utf8'); + } catch { + // A template legitimately need not ship a Dockerfile. + return { pinned: false, reason: 'no Dockerfile in the scaffolded project' }; + } + + const lines = text.split('\n'); + const fromIdx = lines.findIndex((line) => RUNTIME_FROM_RE.test(line)); + if (fromIdx === -1) { + return { + pinned: false, + reason: 'no `FROM ghcr.io/objectstack-ai/objectstack:` line found', + }; + } + + let start = fromIdx; + while (start > 0 && PROSE_COMMENT_RE.test(lines[start - 1])) start--; + + lines.splice(start, fromIdx - start + 1, ...pinnedComment(version)); + const pinnedText = lines.join('\n'); + + // Verify against the rewritten text rather than trusting the splice: a pin + // that quietly did nothing is indistinguishable from one that was not needed, + // and that ambiguity is what shipped #4902 in the namespace rewrite. + const check = pinnedText + .split('\n') + .map((line) => RUNTIME_FROM_RE.exec(line)) + .find((match) => match !== null); + if (!check || check[1] !== version) { + return { pinned: false, reason: 'rewrite did not produce the pinned tag' }; + } + + fs.writeFileSync(dockerfile, pinnedText); + return { pinned: true, tag: version }; +} diff --git a/packages/create-objectstack/src/templates/blank/Dockerfile b/packages/create-objectstack/src/templates/blank/Dockerfile index 58d6e2c2b2..5b8a3db673 100644 --- a/packages/create-objectstack/src/templates/blank/Dockerfile +++ b/packages/create-objectstack/src/templates/blank/Dockerfile @@ -20,9 +20,12 @@ RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── # Ships Node + @objectstack/cli with `os start`, a non-root user, the # /api/v1/health HEALTHCHECK, and OS_ARTIFACT_PATH/OS_PORT preset (port 8080) -# — see docker/README.md in the framework repo. Pin the tag to the -# @objectstack/cli version in your package.json so the runtime matches the -# CLI that built the artifact. +# — see docker/README.md in the framework repo. +# +# Dependencies were not installed while scaffolding, so the tag below could +# not be resolved for you. `latest` floats to whatever release is newest, +# which is unrelated to the @objectstack/cli your app resolves — pin it to +# that version, the CLI that builds your artifact, before you deploy. FROM ghcr.io/objectstack-ai/objectstack:latest COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json From 110c59f06a99830264a3f5aee6572a9ab1db345b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 11:07:03 +0000 Subject: [PATCH 2/2] test(create-objectstack): pin the scaffolder wiring too, not just the pin helper (#9017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavioural tests call pinRuntimeImage directly, so removing the call from index.ts would have left them green while every scaffolded app kept `latest`. index.ts cannot be imported (program.parse() at module scope), so its text is asserted — the same compromise template-consistency.test.ts already makes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --- .../src/runtime-image.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/create-objectstack/src/runtime-image.test.ts b/packages/create-objectstack/src/runtime-image.test.ts index 30a8092f96..7522579f34 100644 --- a/packages/create-objectstack/src/runtime-image.test.ts +++ b/packages/create-objectstack/src/runtime-image.test.ts @@ -211,6 +211,36 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { }); }); + // The tests above call pinRuntimeImage directly, so on their own they would + // stay green if the scaffolder simply stopped calling it — every user would + // get `latest` and nothing would go red. index.ts cannot be imported to close + // that behaviourally (it calls program.parse() at module scope), so its TEXT + // is asserted, the same compromise template-consistency.test.ts already makes + // for the skills-install command. + describe('scaffolder wiring', () => { + const source = fs.readFileSync(path.join(pkgRoot, 'src', 'index.ts'), 'utf8'); + + it('resolves and pins after installing', () => { + expect( + source, + 'index.ts no longer reads the resolved CLI version — the scaffolded ' + + 'Dockerfile would keep the floating `latest` tag (#9017)', + ).toContain('readResolvedCliVersion(targetDir)'); + expect( + source, + 'index.ts no longer pins the runtime image — the scaffolded Dockerfile ' + + 'would keep the floating `latest` tag (#9017)', + ).toContain('pinRuntimeImage(targetDir, resolved)'); + }); + + it('pins only when the install actually succeeded', () => { + // Without node_modules there is no resolved version, so a pin attempted + // after a FAILED install would silently do nothing while the comment + // still promised it had happened. + expect(source).toMatch(/if \(installed\)[\s\S]{0,400}pinRuntimeImage/); + }); + }); + describe('readResolvedCliVersion', () => { it('reads the installed CLI version', () => { installCli(dir, '17.4.2');