Skip to content

Commit f2f09e4

Browse files
fix(create-objectstack): pin the scaffolded Dockerfile's runtime image to the CLI that builds the artifact (#9115)
* fix(create-objectstack): pin the scaffolded Dockerfile's runtime image to the CLI that builds the artifact (#9017) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza * test(create-objectstack): pin the scaffolder wiring too, not just the pin helper (#9017) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4dc8a61 commit f2f09e4

6 files changed

Lines changed: 520 additions & 6 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"create-objectstack": patch
3+
---
4+
5+
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)
6+
7+
`src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest`
8+
directly beneath a comment instructing the reader to "pin the tag to the
9+
`@objectstack/cli` version in your package.json so the runtime matches the CLI that built
10+
the artifact" — an instruction the scaffold itself did not follow. Every app made with
11+
`npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s
12+
tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the
13+
production pin.
14+
15+
Measured on scaffolded output rather than the template's bytes, before the fix:
16+
17+
```
18+
emitted package.json cli range : ^17.0.0
19+
emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest
20+
agreement (tag vs cli range) : DISAGREE
21+
```
22+
23+
**The tag is resolved after `install`, from the installed CLI — not from the generated
24+
`package.json`.** That file carries a caret RANGE, and the two are not interchangeable:
25+
npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a
26+
runtime image *older* than the CLI that built the artifact — breaking the same promise in
27+
a new way. The rolling `:17` tag does match the range's float window but is exactly what
28+
the tag table tells production not to use. The resolved version is the only value that
29+
makes the sentence true, and it is the rule the repo already applies for this purpose in
30+
`.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the
31+
generated project actually resolved to — NOT a hardcoded `latest`").
32+
33+
**Both halves move together.** Pinning the line while leaving an imperative to pin by hand
34+
would relocate the contradiction rather than remove it, so the comment above the `FROM`
35+
line is replaced in the same rewrite. With `--skip-install` there is no resolved version:
36+
the tag stays `latest` and the comment keeps telling the reader to pin — which is true on
37+
that path, because there the user really must do it by hand.
38+
39+
The regression proof asserts on **scaffolded output**, never on the template: it scaffolds
40+
with the real copy/sync/pin path, plants an installed CLI whose version is deliberately
41+
*not* the range's floor (the normal case, and the one that a package.json-derived tag
42+
would get wrong), and checks the emitted `FROM` tag against the emitted `package.json`
43+
range with a satisfies-check rather than equality.
44+
45+
`.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image
46+
under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two
47+
hand-matched literals; had they skewed, Docker would have quietly pulled the last
48+
published image instead of the one built from this checkout, and the job's own stated
49+
hermeticity would have been false while it stayed green.

.github/workflows/scaffold-e2e.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,29 @@ jobs:
215215
# resolved version keeps the image in lockstep whether the project got
216216
# the repo RC, the `latest` fallback (see the install step), or a
217217
# published stable.
218+
#
219+
# The TAG this image is built under is READ OUT OF the generated
220+
# Dockerfile rather than hardcoded (#9017). The two used to be
221+
# hand-matched literals — `:latest` here and `:latest` in the template —
222+
# and the scaffolder now pins that tag to the CLI a project resolved, so
223+
# a hardcoded tag here would stop naming the image the scaffolded
224+
# `FROM` asks for. Docker would then silently pull the last PUBLISHED
225+
# image instead of the one built from this checkout, and (b) above would
226+
# be quietly false while the job stayed green. Deriving both from one
227+
# source is what makes that skew impossible rather than merely fixed.
228+
# (This job scaffolds with --skip-install, so today that tag reads
229+
# `latest`; it follows the file if that ever changes.)
218230
run: |
219231
CLI_VERSION=$(node -p "require('$RUNNER_TEMP/e2e-app/node_modules/@objectstack/cli/package.json').version")
232+
RUNTIME_TAG=$(sed -n 's|^FROM ghcr\.io/objectstack-ai/objectstack:||p' \
233+
"$RUNNER_TEMP/e2e-app/Dockerfile")
234+
if [ -z "$RUNTIME_TAG" ]; then
235+
echo "::error::no FROM ghcr.io/objectstack-ai/objectstack:<tag> 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"
236+
exit 1
237+
fi
220238
echo "Runtime image will bundle @objectstack/cli@$CLI_VERSION (matches the scaffolded artifact's protocol)"
221-
docker build -t ghcr.io/objectstack-ai/objectstack:latest \
239+
echo "Tagging it ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG (read from the scaffolded Dockerfile)"
240+
docker build -t "ghcr.io/objectstack-ai/objectstack:$RUNTIME_TAG" \
222241
--build-arg OS_CLI_VERSION="$CLI_VERSION" \
223242
"$GITHUB_WORKSPACE/docker"
224243

packages/create-objectstack/src/index.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@
2424
* - objectstack.config.ts manifest.id and manifest.name string literals
2525
* - README.md first H1
2626
*
27-
* Finally we run `<pm> install` and (best-effort) install the ObjectStack
28-
* skills bundle via `npx skills add objectstack-ai/objectstack/skills --all`.
27+
* Then we run `<pm> install` — and only afterwards can the Dockerfile's runtime
28+
* image tag be pinned, because the template carries a caret range and the tag
29+
* has to name the @objectstack/cli version npm actually resolved (#9017). With
30+
* `--skip-install` there is no resolved version, so the template keeps `latest`
31+
* and its comment keeps telling the reader to pin by hand — true in that path.
32+
*
33+
* Finally we (best-effort) install the ObjectStack skills bundle via
34+
* `npx skills add objectstack-ai/objectstack/skills --all`.
2935
* The `/skills` subpath scopes discovery to the curated, customer-published
3036
* catalog — repo-internal skills (e.g. under `.claude/skills/`) must never
3137
* reach scaffolded projects.
@@ -46,6 +52,7 @@ import {
4652
findStaleNamespacePrefixes,
4753
} from './rewrite-identity.js';
4854
import { lookupTemplate, templateNames } from './template-registry.js';
55+
import { readResolvedCliVersion, pinRuntimeImage } from './runtime-image.js';
4956

5057
const __filename = fileURLToPath(import.meta.url);
5158
const __dirname = path.dirname(__filename);
@@ -348,14 +355,39 @@ const program = new Command()
348355

349356
if (!options.skipInstall) {
350357
printStep('Installing dependencies...');
358+
let installed = false;
351359
try {
352360
const pm = detectPackageManager();
353361
execSync(`${pm} install`, { stdio: 'inherit', cwd: targetDir });
362+
installed = true;
354363
console.log('');
355364
} catch {
356365
printWarning('Dependency installation failed. Run `npm install` manually.');
357366
console.log('');
358367
}
368+
369+
// Pin the Dockerfile's runtime image to the CLI that will build this
370+
// project's artifact — knowable only now, because the template pins a
371+
// caret RANGE and npm has just resolved it (#9017). Skipped without an
372+
// install: with no node_modules there is no resolved version, and the
373+
// template's own comment then correctly tells the user to pin by hand.
374+
if (installed) {
375+
const resolved = readResolvedCliVersion(targetDir);
376+
if (resolved) {
377+
const result = pinRuntimeImage(targetDir, resolved);
378+
if (result.pinned) {
379+
printSuccess(`Dockerfile runtime image pinned to ${result.tag}`);
380+
} else {
381+
// Not fatal: the project is complete, the tag is just less
382+
// precise than it could be. runtime-image.test.ts is the guard.
383+
printWarning(
384+
`Could not pin the Dockerfile runtime image (${result.reason}); ` +
385+
`it still reads \`latest\` — pin it to ${resolved} before deploying.`,
386+
);
387+
}
388+
console.log('');
389+
}
390+
}
359391
}
360392

361393
if (!options.skipInstall && !options.skipSkills) {

0 commit comments

Comments
 (0)