Skip to content

fix(create-objectstack): pin the scaffolded Dockerfile's runtime image to the CLI that builds the artifact - #9115

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-9017-scaffold-pin-runtime-image-tag
Aug 16, 2026
Merged

fix(create-objectstack): pin the scaffolded Dockerfile's runtime image to the CLI that builds the artifact#9115
os-project-manager merged 2 commits into
mainfrom
claude/issue-9017-scaffold-pin-runtime-image-tag

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#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 your package.json" — an instruction the scaffold itself did not follow, baked into every app made with npx create-objectstack. docker/README.md's tag table already scopes latest to quick starts and documents X.Y.Z as the production pin.

Measured on scaffolded output, not 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

This lands option 1 (pin), the direction triage settled. Option 2 was not taken and no impracticality is being claimed.

The tag is resolved after install, not read off package.json

The generated package.json 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 — the exact promise the comment makes, broken in a new way. The rolling :17 tag does match the range's float window but is what the tag table tells production not to use.

So the tag comes from the installed CLI (node_modules/@objectstack/cli), which is the same rule the repo already applies for the same reason in scaffold-e2e.yml:219 — "Pin the runtime's CLI to the SAME version the generated project actually resolved to — NOT a hardcoded latest", because during an RC window a fixed tag skews protocol majors and os start refuses to boot.

Registry contents were checked rather than assumed: ghcr publishes exact tags (15.1.0, 16.0.0, 16.1.0, 17.0.0, and prereleases like 17.0.0-rc.6), so a resolved version always names a tag that exists. Worth recording: 15.0.0 is absent from that list, so older exact tags are not guaranteed to persist indefinitely.

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 rewritten in the same pass. With --skip-install there is no resolved version: the tag stays latestand 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 template's comment was reflowed so the swappable paragraph is separated by a bare #. That is load-bearing, not cosmetic: the rewrite walks up from the FROM line over contiguous prose comments, and without the separator it would swallow the section header too. Ablation B below is what proves it.

The e2e coupling this change had to keep intact

scaffold-e2e.yml builds the runtime image from this checkout, tags it, and then builds the scaffolded app against it, with :203-205 naming hermeticity as the reason — "no dependency on a prior release having published the tag". That tag and the template's FROM tag were two hand-matched literals authored in the same commit (b50c0ef27). Had they skewed, Docker would have quietly pulled the last published image instead of the one built here, and that stated hermeticity would have been false while the job stayed green.

The workflow now reads the tag out of the generated Dockerfile instead of hardcoding :latest, so the two are derived from one source and cannot skew. Nothing was traded away to accommodate the pin: no --pull flip, no removed leg, no continue-on-error. That job scaffolds with --skip-install, so the tag it reads today is still latest and its behaviour is unchanged; it follows the file if that ever changes.

Residual gap, stated rather than hidden: because the e2e uses --skip-install, the pinned shape gets no e2e coverage — it is covered by the package suite below.

Verification — union run at 110c59f06, the final commit

dispatch-gates.mjs reported "No check family names the given paths in its own source" for the dispatched surface, so the test is the gate here. Every assertion runs against scaffolded output produced by the real copy/sync/pin path; none greps the template.

  • pnpm --filter create-objectstack test — 4 files, 58 passed (14 new)
  • pnpm --filter create-objectstack typecheck — clean
  • Re-derived against the actual changed paths, which named 10 families the dispatch list did not: check:changeset-gate-self-tests, check:node-version, check:objectui-changeset, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:nul-bytes, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-shard-attestation, plus check:docs-image-tagall pass
  • Convention-triggered by the new test file: check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverageall pass
  • check:type-check-debt --re-measurenot measured: it refuses on an unbuilt worktree by design, and building the 55-package closure was disproportionate here. create-objectstack appears in no ledger, and the structural half is green, so no entry is required — but that is reasoning, not a measurement, and CI runs it for real.

Reverse verification (direction predicted before running: red in all three; no dist/ is involved — vitest transforms src directly, so no rebuild applies):

AblationResult
pinRuntimeImage returns success without writingred — 4 pinned-path tests
template reverted to the pre-fix comment shapered — 2, including "leaves the rest of the Dockerfile intact", which is what proves the reflow load-bearing
the pin call removed from index.tsred — 2 wiring tests

That third ablation is why the wiring is asserted at all: the behavioural tests call pinRuntimeImage directly, so without it the scaffolder could simply stop calling the pin and every user would get latest with nothing going red. index.ts cannot be imported (program.parse() at module scope), so its text is asserted — the same compromise template-consistency.test.ts already makes for the skills command.

Changeset

A real changeset, not skip-changeset: this changes what npx create-objectstack emits, which is published user-visible behaviour of the create-objectstack package. Patch — a bug fix, no ADR-0087 block since nothing breaking is declared.


Generated by Claude Code

…e 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
… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): create-objectstack, objectstack-blank.

6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via create-objectstack)
  • content/docs/ai/skills.mdx(via create-objectstack)
  • content/docs/deployment/self-hosting.mdx(via create-objectstack, packages/create-objectstack/src/templates/blank)
  • content/docs/getting-started/index.mdx(via create-objectstack)
  • content/docs/getting-started/your-first-project.mdx(via create-objectstack)
  • content/docs/plugins/packages.mdx(via create-objectstack)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v15.mdx(via create-objectstack)
  • content/docs/releases/v9.mdx(via create-objectstack)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create-objectstack: blank template Dockerfile uses :latest while its own comment says to pin the CLI version

2 participants

@os-project-manager@claude