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
49 changes: 49 additions & 0 deletions .changeset/scaffold-runtime-image-pinned.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion .github/workflows/scaffold-e2e.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:<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"
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"

Expand Down
36 changes: 34 additions & 2 deletions packages/create-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,14 @@
* - objectstack.config.ts manifest.id and manifest.name string literals
* - README.md first H1
*
* Finally we run `<pm> install` and (best-effort) install the ObjectStack
* skills bundle via `npx skills add objectstack-ai/objectstack/skills --all`.
* 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
* `--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.
Expand All@@ -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);
Expand DownExpand Up@@ -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) {
Expand Down
Loading
Loading