Skip to content

ci(docker): publish an on-demand public framework build base image - #31454

Merged
os-zhuang merged 3 commits into
mainfrom
claude/project-thread-nkr544
Nov 8, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/project-thread-nkr544

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sam · project thread
Part of #31441. This PR adds the workflow and the image definition. The card's other half stays open after merge: the first dispatch for a named commit on main, setting the new package Public once (GitHub creates a container package private), and the digest and build time read from that run.

维护者速读

  • 改了什么:新增 framework-build-base.yml 和 docker/framework-build-base/:按需把 main 上某个框架提交打成公开镜像 ghcr.io/objectstack-ai/framework-build-base:SHA,里面是装好依赖、建好全部包和 console 的框架源码树 /repo/objectstack。
  • 为什么改:cloud 三个 Dockerfile 现在每次冷缓存或升 pin 都在私有仓计费分钟里从头建框架;这个镜像在公开仓(免费分钟)建一次,cloud 卡 11b(cloud#3792)再改成 FROM 它。
  • 风险与回滚:只新增文件,不碰现有 workflow 和运行时镜像;镜像公开,所以构建上下文排除 .git、checkout 不留凭据、检查脚本断言镜像里没有 .git。回滚 = revert 本 PR,已推的标签留在 ghcr 不影响任何东西。
  • 先看:.github/workflows/framework-build-base.yml 的 publish 任务(只认 main 上的提交、标签已存在就不重建、匿名读回);docker/framework-build-base/Dockerfile 的两个 RUN(参数与 cloud Dockerfile 框架阶段逐项相同);check-image.sh。
  • 两分钟验证:打开本 PR 的 Framework Build Base Image 检查,作业摘要里有 dry run 的构建耗时和镜像读数(node/pnpm 版本、console 构建标识、镜像大小),什么都没推。
  • 需要你做的:审批本 PR(.github/workflows/** 是 Tier H 受管面);合并后的第一次 dispatch 和把 ghcr 包设为 Public(一次,不可撤回)写在 Verification 里。

What changes

  • .github/workflows/framework-build-base.yml (new). Three triggers, none per commit:
    • workflow_dispatch with sha (a full 40-hex commit on main) and an optional rebuild boolean;
    • repository_dispatch, event type framework-build-base, with client_payload.sha: the hook a downstream pin move can call later;
    • pull_request touching the workflow or docker/framework-build-base/**: the dry run. Job verify builds the PR's base commit with the PR's Dockerfile, loads it into the runner, looks inside, and pushes nothing. Its permissions are contents: read only.
  • Job publish (the only job with packages: write) runs only on a dispatch from main or a repository_dispatch. It:
    1. refuses a requested commit that is not 40-hex lowercase, or not on main (the compare API must answer ahead or identical);
    2. reads the registry: a tag that already exists reports its digest and builds nothing, unless rebuild: true;
    3. checks out the image definition from this run's main and the requested commit as the build context, both with persist-credentials: false;
    4. builds linux/amd64 and pushes ghcr.io/objectstack-ai/framework-build-base:SHA (no provenance or SBOM manifests, so the tag is one plain image manifest);
    5. logs out of ghcr.io and pulls the pushed digest anonymously, then runs check-image.sh on it;
    6. writes the commit, tag, digest, build wall seconds, the anonymous-read outcome and the image readings to the job summary, with a ready FROM ...:SHA@DIGEST line.
  • docker/framework-build-base/Dockerfile (new). The framework stage of cloud's apps/cloud, apps/objectos and apps/objectos-ee Dockerfiles at cloud ac69aac, with the same arguments: node:22-slim, python3 make g++ ca-certificates git, CI=true, OS_SKIP_DTS=1, corepack pnpm@10.31.0, PNPM_HOME=/pnpm, store /pnpm/store in a cache mount, the in-tree authorable-surface anchor assert, pnpm install --frozen-lockfile --prefer-offline --prod=false against registry.npmjs.org, turbo run build --filter='!@objectstack/docs' --filter='!./ui/**' --concurrency=1 at a 12 GB heap, then the typed Console turbo run build:dist --filter=@objectstack/console --concurrency=1 with VITE_APP_VERSION = first 8 hex of the commit, then rm -rf ui. The tree lands at /repo/objectstack, the path cloud's link: dependencies resolve to.
  • docker/framework-build-base/Dockerfile.dockerignore (new): .git, node_modules, .pnpm-store, .turbo, the artifact-only list the three cloud ignore files share.
  • docker/framework-build-base/check-image.sh (new): runs inside the image with no network and fails when there is a .git anywhere under /repo, the workspace is not pnpm-installed, spec or core has no dist, the Console index.html is missing, ui/ is still there, no Console asset carries the commit's 8-hex build identity, or OS_SKIP_DTS is not 1. It prints node and pnpm versions, counts, and the image size.

Why

Cloud pins the framework by commit, usually between releases, and links it as a workspace by path, so the existing runtime image (ghcr.io/objectstack-ai/objectstack, an npm install of a released CLI) cannot serve it. Each of cloud's three image builds therefore installs and builds the whole framework and the Console itself, cold on every pin move or cache eviction, in a private repository's billed minutes. This image does that once per commit in this public repository. Cloud card 11b (objectstack-ai/cloud#3792, blocked on this card) switches the three Dockerfiles to start FROM it.

Tests

  • Local gates derived by node scripts/pm/dispatch-gates.mjs --commands at b91dc73d for this change set: 35 commands, all exit 0 (closing-keyword parity, self-test wiring, step collectors, workflow step-name quoting, workflow status functions, required contexts, stall-guard budget and headroom, pnpm acquisition, node version, NUL bytes and the rest). check:pm-dispatch-gates runs separately; its result is in the card's report.
  • bash -n on check-image.sh; the workflow parses as YAML with jobs verify, publish.
  • The real build is this PR's own verify run (the pull_request trigger matches this PR's paths). Its build wall and image readings are in that run's job summary and in the card's report.

Acceptance notes

  • One Console for all three downstream images. The self-managed image built its Console with three VITE_SENTRY_* levers (empty DSN, kill switch off, PII default off); the other two did not. This image builds with the levers on. On this tree the Console no longer reads any of the three (client error reporting is runtime configuration, ui/packages/app-shell/src/observability/sentry.ts), so the levers change no behaviour; they stay so a build-time DSN can never ship silently in the customer artefact.
  • Registry. cloud's apps/cloud and apps/objectos builders set npm_config_registry and COREPACK_NPM_REGISTRY to npmmirror as image ENV, and apps/objectos installs the framework from it. This image installs from registry.npmjs.org and sets no registry ENV. The lockfile pins integrity, so the installed tree is the same; a downstream stage that wants the mirror for its own install sets it after FROM.
  • .gitignore. Two of the three cloud ignore files also exclude **/.gitignore; the self-managed one does not, and the cloud file records that the line moves no turbo hash. This image keeps .gitignore, as the self-managed image does.
  • Trust. publish's if: (main, or a repository_dispatch) is the policy, not a security boundary: anyone who can push a branch could edit it there. The commit check is what keeps a branch head or a fork's pull ref out of a published image.
  • Dispatching it. AGENTS.md Prime Directive 15 reserves dispatching "any other publish-capable workflow" to the maintainer, and the fleet relay's workflow_dispatch op allows only closed, fixed inputs per file, so it could not carry a commit anyway. The first dispatch is the maintainer's click unless a ruling says otherwise. A workflow_dispatch trigger also only exists for this file once it is on main, so nothing could be dispatched from this branch.
  • Budget. timeout-minutes: 60 on both jobs, with no reading of this workflow on record yet; the same stage runs about 20 minutes cold inside cloud's image builds on the same runner class. The reading from this PR's verify run should replace the note.

Verification

  • This PR's Framework Build Base Image / Build framework build base (dry run, no push) check: job summary shows the build wall and the readings.
  • After merge: Actions, Framework Build Base Image, Run workflow on main with a commit (for example cloud's current .objectstack-sha, 98583cd692c6a67891617529d2f7aec4963859b8). The summary prints the digest. Then, logged out: docker pull ghcr.io/objectstack-ai/framework-build-base:SHA succeeds once the package is Public.
  • A digest is obtained from that run's summary, or at any time with docker buildx imagetools inspect ghcr.io/objectstack-ai/framework-build-base:SHA, or by dispatching the same commit again (an existing tag reports its digest and builds nothing).

Generated by Claude Code

…ild-base)

A new workflow builds ghcr.io/objectstack-ai/framework-build-base:<sha> for one
framework commit on main: workspace dependencies installed, every package built
typeless, the Console built typed into packages/console/dist, with the same
arguments as the framework stage downstream images run today. Downstream images
that link the framework by path can start FROM it instead of building the whole
framework on every cold cache or pin move.

Triggers: workflow_dispatch (sha, optional rebuild) from main, repository_dispatch
(framework-build-base, client_payload.sha), and a pull_request dry run that builds
the base commit with the PR's Dockerfile and pushes nothing. A tag is written once;
an existing tag reports its digest and builds nothing. The published image is read
back logged out, and docker/framework-build-base/check-image.sh looks inside it.

Claude-Session: https://claude.ai/code/session_01QUs3BuMyARAhKn8scCUz3t
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Nov 8, 2026
… the first build reading

The dispatch-gates live guard requires every paths-filtered workflow to discover a
check family or declare why not; this one builds an image and checks it with a
script that needs the built image, so it declares no-check-families. The publish
budget note now cites the dry run's cold reading (build 15m25s, job 18m45s).

Claude-Session: https://claude.ai/code/session_01QUs3BuMyARAhKn8scCUz3t
Co-authored-by: Claude <noreply@anthropic.com>
… in the marker census

The live marker census pins every declaration by name, so the new workflow's
declaration takes its row (eleven, from ten).

Claude-Session: https://claude.ai/code/session_01QUs3BuMyARAhKn8scCUz3t
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:cloud#3 · os-zhuang · session_01QUs3BvJMebPthXe3qaXmcu

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a54e58901b588fb32d2eb2dcf0b6cc97da8cac1c
Local-runs: none
Scope: .github/workflows/framework-build-base.yml + docker/framework-build-base/Dockerfile, docker/framework-build-base/Dockerfile.dockerignore, docker/framework-build-base/check-image.sh

Independent review of CI cost card 11a (#31441), ordered by seat 3 at the fable tier (Sam 2026-11-08 07:12Z: reviews of the CI cost cards run on fable). Inputs: the PR body, the net diff of the head against main (b91dc73), the 60 check runs on the head (48 success, 12 skipped, none failed), the three Framework Build Base Image runs, and cloud ac69aac for the parity reading. Nothing was built or run locally.

① Derived judgments

  • New governed path .github/workflows/framework-build-base.yml: three triggers, none per commit; permissions: contents: read at the top; only publish adds packages: write. Right.
  • Public image contents = the framework tree at one main commit, dev dependencies installed, built. The repository is public, so the only secret-bearing input is a checkout's .git. Three independent layers keep it out: persist-credentials: false on both checkouts, **/.git in Dockerfile.dockerignore (read by BuildKit from the Dockerfile's own directory, so it applies whatever the context is), and check-image.sh failing on any .git under /repo. The dry run on this head (run 42466724546) passed that assertion, which proves the ignore file is honoured with file: outside the context. Right.
  • No token reaches a layer: the only build arg is FRAMEWORK_SHA; the ghcr login lives in the runner's Docker config, not in the build; GH_TOKEN is used by one gh api compare read. Right.
  • Trust boundary of publish: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' excludes the PR dry run and any workflow_dispatch from a branch; repository_dispatch always runs main's copy of the file. The sha gate (40 lowercase hex, then compare SHA...main must answer ahead or identical) refuses a branch head, a fork pull ref, or a squash-merged branch commit. So the trigger can only package a commit already reachable from main. Right. Firing repository_dispatch needs a token with write on this repo; the fleet relay's only event type is fleet-write (fleet-write.yml line 64, dispatch.mjs RELAY_EVENT_TYPE), and WORKFLOW_DISPATCH_INPUTS holds closed inputs, so no agent path today can start a publish. First dispatch is the maintainer's click, as the PR body says.
  • Dry run on pull_request: builds the PR's base commit with the PR's Dockerfile, push: false, load: true, contents: read only, no registry login. A fork PR can run it, at zero billable cost, with no secret in reach. Right.
  • Tag idempotence: imagetools inspect before the build; an existing tag reports its digest and skips every build step, then re-reads only the manifest anonymously. rebuild: true (workflow_dispatch only) rebuilds and re-pushes; a replaced tag changes the digest, and a downstream pinned by TAG@DIGEST keeps resolving while ghcr keeps the untagged manifest. The summary's FROM ...:SHA@DIGEST line gives 11b the pin. Right.
  • Concurrency: one group per requested sha, no cancel; PR groups cancel on the next push. A second request for the same commit waits, then finds the tag. Right.
  • Self-test (scripts/pm/dispatch-gates.self-test.mjs): the live marker census gains the row .github/workflows/framework-build-base.yml:32 no-check-families and the count moves ten to eleven in both assertions. Line 32 of the workflow is the declaration, with its reason on the same line. Lint · PM dispatch-gates is green on the head. Right. This file is outside the governed face and is the lane seat's TIER_DEFAULT remainder; named here so the ORDER's item (5) is answered.

② Semver level

No package is published by this diff (a workflow, a Dockerfile, an ignore file, a shell script, a self-test). skip-changeset is right. No Clause-②: line is owed.

③ Boundary flags

  • Budget (Sam 2026-11-08 07:19Z, zero overage with headroom): objectstack is public, and the Actions usage API reports billable.UBUNTU.total_ms = 0 for run 42466724546. Every run of this workflow is outside cloud's included minutes. Readings on this head: job 19m21s, build step 16m48s, image 838,622,123 bytes, node v22.23.4, pnpm 10.31.0, 79 dist dirs, 2,438 console assets (run 42466724546). Publish adds a push and an anonymous pull of about 0.8 GB, so about 20 to 25 minutes per distinct commit, once. Dry runs fire only on PRs touching the two paths. Projection: one publish per cloud pin move (a few per day at most) plus a handful of dry runs per month, all at zero billable minutes. The saving is cloud card 11b's to realise.
  • Package visibility: first push creates the package private; the anonymous-pull step is red until the maintainer sets it Public once (irreversible). Documented in the workflow header and the PR body. Not a finding; it is the maintainer step the card keeps open.
  • Parity with cloud ac69aac (apps/cloud, apps/objectos, apps/objectos-ee framework stage), argument for argument: node:22-slim; python3 make g++ ca-certificates git; CI=true; OS_SKIP_DTS=1 (stays set in the image, as in the cloud builder stage); PNPM_VERSION=10.31.0 = packageManager on main; PNPM_HOME=/pnpm; pnpm config set store-dir /pnpm/store; COPY to /repo/objectstack; the authorable-surface baseline assert; pnpm install --frozen-lockfile --prefer-offline --prod=false with npm_config_registry=https://registry.npmjs.org and NODE_OPTIONS=--max-old-space-size=12288; turbo run build --filter='!@objectstack/docs' --filter='!./ui/**' --concurrency=1; console env -u OS_SKIP_DTS ... turbo run build:dist --filter=@objectstack/console --concurrency=1 with VITE_APP_VERSION = first 8 hex of the pin; test -f packages/console/dist/index.html; rm -rf ui. All match. Four deviations, none blocking, all for 11b to know:
    1. No COREPACK_NPM_REGISTRY / npm_config_registry=npmmirror image ENV (apps/cloud and apps/objectos set them in the builder stage and apps/objectos installs cloud's own tree through that ENV). The framework install already overrode to npmjs in all three, so the framework tree is identical by lockfile; 11b sets the mirror ENV after FROM if it wants it for the cloud install.
    2. VITE_SENTRY_DSN= / VITE_SENTRY_ENABLED=false / VITE_SENTRY_SEND_DEFAULT_PII=false on the console build for all three (only objectos-ee had them). The console on main reads none of them (ui/apps/console CHANGELOG: VITE_SENTRY_DSN and siblings retired; only VITE_SENTRY_RELEASE stays build-time and is not set here). No behaviour change.
    3. fw-node-cache cache mount on both RUNs (only apps/cloud had it). A cache mount, nothing in a layer.
    4. .gitignore kept in the tree (apps/cloud and apps/objectos exclude it from their context; objectos-ee keeps it). cloud's own changeset records this moves no turbo hash.
  • Dockerfile.dockerignore is artifact-only (.git, node_modules, .pnpm-store, .turbo), so packages/spec/src/data and the committed baseline arrive. Right.

④ Design judgment

.github/workflows/framework-build-base.yml against the scenario it is accepted against (a public builder image cloud can FROM without drift, at zero billed minutes, without widening who can publish): holds. Each implementing file agrees with it: the Dockerfile reproduces the cloud framework stage, the ignore file keeps the checkout metadata out, check-image.sh asserts the promises a downstream FROM relies on (no .git, pnpm-installed, spec and core built, console dist with the 8-hex identity, ui/ gone, OS_SKIP_DTS=1) inside the image with no network.

Findings:

  • nit (accepted deviation): the "tag written once" promise depends on the imagetools inspect read succeeding. A transient registry failure reads as "no tag" and rebuilds, replacing the tag with a new digest. Digest pins survive, so no consumer breaks; a follow-up could tell a 404 apart from any other error before building.
  • nit: rebuild is only a workflow_dispatch input; a repository_dispatch cannot ask for it. Documented by the header text; add client_payload.rebuild when the downstream hook exists.
  • nit (follow-up card at 11b time): no retention for old SHA tags. Public package storage is not billed, so this is tidiness, not budget.
  • nit: actions/checkout@v7 is tag-pinned while the Docker actions are sha-pinned. Matches the other 73 actions/checkout@v7 uses in this repo; consistency with the repo wins.

No must-fix, no should-fix. The nits move no head; this record covers a54e5890.

Implemented-by: claude/project-thread-nkr544
Reviewed-by: session_01QUs3BvJMebPthXe3qaXmcu

VERDICT: PASS

Reviewed at 2026-11-08T11:48Z · do not approve, merge, ready or label from this record; Tier H lands by the maintainer's hand.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot requested a review from hotlong November 8, 2026 11:51
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · os-zhuang · session_01QUs3BvRcDHXqRU4TSVE4u6

ACCEPT · objectstack#31441 (card 11a) · head a54e58901b588fb32d2eb2dcf0b6cc97da8cac1c · 2026-11-08T11:51Z

席位核对

  1. Shape: one draft PR, Part of #31441 (the card stays open for the first publish, setting the package Public once, and the digest reading). It is new files only: one workflow, the image definition, a check script and one self-test row. GOVERNED, Tier H (.github/workflows/**), so a human merge is the record.
  2. Independent review: contract review PASS at this head on the fable tier, as Sam ruled for CI-cost card reviews (record 6721108948). Security: only publish holds packages:write, it runs only from main or repository_dispatch, the sha must be on main, credentials are not persisted, and .git is excluded and asserted absent. Budget: this repo is public (0 billable ms measured), with no per-commit trigger. Parity with cloud ac69aac's framework stage holds argument for argument. Four nits, none blocking.
  3. Gates: every PR check is green on this head, including the dry run Build framework build base (dry run, no push) (build 16m48s, image 839 MB, nothing pushed).
  4. Handoff: review requested from hotlong. The 维护者速读 is in the PR body. Under Sam's interim rule (2026-11-08 09:51Z), the seat labels it pm:awaiting-merge and does not ready or arm it.
  5. After merge (owed on CI cost 11a (domain:cloud): publish an on-demand public framework build base image from objectstack main for cloud Dockerfiles #31441): the first workflow_dispatch for a named main commit, setting ghcr.io/objectstack-ai/framework-build-base Public once (a maintainer act, one-way), and the digest plus build time read from that run. Then seat 2's cloud#3792 (11b) unblocks, and the seat sends it the image name and digest.

@objectstack-fleet objectstack-fleet Bot added the pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue label Nov 8, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review November 8, 2026 14:36
@os-zhuang
os-zhuang self-requested a review as a code owner November 8, 2026 14:36
@os-zhuang
os-zhuang enabled auto-merge November 8, 2026 14:36
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit e3ab0aa Nov 8, 2026
66 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-nkr544 branch November 8, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants