Skip to content

Commit e3ab0aa

Browse files
ci(docker): publish an on-demand public framework build base image (#31454)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HP1HcuuzFq3zR8mqTyyMaHCW)_ 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](https://claude.ai/code/session_01QUs3BuMyARAhKn8scCUz3t)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d53131b commit e3ab0aa

5 files changed

Lines changed: 539 additions & 2 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
name: Framework Build Base Image
2+
3+
# Builds and pushes ghcr.io/objectstack-ai/framework-build-base:<sha> from
4+
# docker/framework-build-base/Dockerfile: ONE framework commit on `main`, its
5+
# workspace dependencies installed, every package built, and the Console built
6+
# into packages/console/dist. It is a builder stage for downstream images that
7+
# link the framework by path and pin it by commit, so they can start FROM it
8+
# instead of installing and building the whole framework on every cold cache.
9+
# It is NOT the runtime image; that one is docker-publish.yml.
10+
#
11+
# On demand only, never per commit:
12+
# - workflow_dispatch, from main, with `sha` = a full commit on main;
13+
# - repository_dispatch, event type `framework-build-base`, with
14+
# client_payload {"sha": "<40 hex>"}: the hook a downstream pin move can call;
15+
# - pull_request touching this file or docker/framework-build-base/: the dry
16+
# run of a change to the image definition. It builds the PR's base commit
17+
# with the PR's Dockerfile and pushes nothing; its job has no packages grant.
18+
#
19+
# A tag is written once. A request for a commit whose tag already exists reports
20+
# the existing digest and builds nothing (dispatch with `rebuild: true` to
21+
# replace it), so a downstream can ask on every pin move at no cost.
22+
#
23+
# The image definition always comes from THIS run's checkout of main; only the
24+
# build context is the requested commit. So a commit that predates this file can
25+
# be packaged, and a commit cannot bring its own Dockerfile into a published image.
26+
#
27+
# Visibility: GitHub creates a new container package private on its first push,
28+
# and no API changes that. The maintainer sets it Public once, in the package's
29+
# settings (Danger Zone, Change visibility; it cannot be made private again).
30+
# Until then the anonymous-pull step below is red and names that fix.
31+
32+
# dispatch-gates: no-check-families -- the steps are an image build, a registry read and an in-image check script (docker/framework-build-base/check-image.sh, which needs the built image), not named local checks
33+
34+
on:
35+
workflow_dispatch:
36+
inputs:
37+
sha:
38+
description: 'Full 40-hex framework commit on main to package (e.g. a downstream .objectstack-sha)'
39+
required: true
40+
type: string
41+
rebuild:
42+
description: 'Rebuild and re-push even when the tag already exists (the digest changes)'
43+
required: false
44+
type: boolean
45+
default: false
46+
repository_dispatch:
47+
types: [framework-build-base]
48+
pull_request:
49+
paths:
50+
- '.github/workflows/framework-build-base.yml'
51+
- 'docker/framework-build-base/**'
52+
53+
permissions:
54+
contents: read
55+
56+
# One build per commit at a time; a second request for the same commit waits,
57+
# then finds the tag and builds nothing. A PR's dry run is superseded by its
58+
# next push.
59+
concurrency:
60+
group: framework-build-base-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.client_payload.sha || inputs.sha }}
61+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
62+
63+
env:
64+
IMAGE: ghcr.io/objectstack-ai/framework-build-base
65+
66+
jobs:
67+
verify:
68+
name: Build framework build base (dry run, no push)
69+
if: github.event_name == 'pull_request'
70+
runs-on: ubuntu-latest
71+
# The same build as `publish` below; see its budget note.
72+
timeout-minutes: 60
73+
permissions:
74+
contents: read
75+
steps:
76+
- name: Free runner disk
77+
run: |
78+
set -euxo pipefail
79+
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
80+
sudo docker image prune -af || true
81+
df -h /
82+
83+
- name: Check out the image definition (this pull request)
84+
uses: actions/checkout@v7
85+
with:
86+
ref: ${{ github.event.pull_request.head.sha }}
87+
path: builder
88+
persist-credentials: false
89+
sparse-checkout: docker/framework-build-base
90+
91+
- name: Check out the framework commit (the pull request's base)
92+
uses: actions/checkout@v7
93+
with:
94+
ref: ${{ github.event.pull_request.base.sha }}
95+
path: objectstack
96+
persist-credentials: false
97+
98+
- name: Set up Docker Buildx
99+
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
100+
101+
- name: Note the build start
102+
id: clock
103+
run: echo "start=$(date +%s)" >> "$GITHUB_OUTPUT"
104+
105+
- name: Build (load into the runner, never push)
106+
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
107+
with:
108+
context: objectstack
109+
file: builder/docker/framework-build-base/Dockerfile
110+
platforms: linux/amd64
111+
push: false
112+
load: true
113+
provenance: false
114+
sbom: false
115+
build-args: |
116+
FRAMEWORK_SHA=${{ github.event.pull_request.base.sha }}
117+
tags: framework-build-base:dry-run
118+
119+
- name: Look inside the image
120+
env:
121+
SHA: ${{ github.event.pull_request.base.sha }}
122+
START: ${{ steps.clock.outputs.start }}
123+
run: |
124+
set -euo pipefail
125+
BUILD_S=$(( $(date +%s) - START ))
126+
READINGS="$(bash builder/docker/framework-build-base/check-image.sh framework-build-base:dry-run "$SHA")"
127+
echo "$READINGS"
128+
{
129+
echo "### Framework build base, dry run (nothing pushed)"
130+
echo
131+
echo "- framework commit (PR base): \`$SHA\`"
132+
echo "- build wall: ${BUILD_S} s"
133+
echo "$READINGS" | sed 's/^/- /'
134+
} >> "$GITHUB_STEP_SUMMARY"
135+
136+
publish:
137+
name: Build & push ghcr.io/objectstack-ai/framework-build-base
138+
# Trusted runs only: a dispatch from main, or a repository_dispatch (which
139+
# always runs main's copy of this file). This `if` is the policy, not a
140+
# security boundary: anyone who can push a branch can also edit it there.
141+
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
142+
runs-on: ubuntu-latest
143+
# One cold reading on record, the `verify` job of run 42464408854: job wall
144+
# 18m45s, of which the build 15m25s (install 33 s, 82 framework tasks
145+
# 1m58s, 74 Console tasks 10m47s, load into the runner 94 s). This job
146+
# skips the load and adds a push and an anonymous pull of about 0.8 GB.
147+
# 60 minutes is about 3x, room for a slow npm or a slow registry.
148+
timeout-minutes: 60
149+
permissions:
150+
contents: read
151+
packages: write
152+
steps:
153+
- name: Resolve the framework commit
154+
id: sha
155+
env:
156+
REQUESTED: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.sha || inputs.sha }}
157+
GH_TOKEN: ${{ github.token }}
158+
REPO: ${{ github.repository }}
159+
run: |
160+
set -euo pipefail
161+
SHA="$(printf '%s' "$REQUESTED" | tr -d '[:space:]')"
162+
if ! printf '%s' "$SHA" | grep -Eq '^[0-9a-f]{40}$'; then
163+
echo "::error::the requested commit is not a full 40-hex lowercase sha"
164+
exit 1
165+
fi
166+
# Only commits on main are packaged: main must be ahead of the commit,
167+
# or be it. Anything else (a branch head, a fork's pull ref) is refused.
168+
if ! STATUS="$(gh api "repos/$REPO/compare/$SHA...main" --jq .status)"; then
169+
echo "::error::cannot compare $SHA with main (is it a commit of $REPO?)"
170+
exit 1
171+
fi
172+
case "$STATUS" in
173+
ahead|identical) ;;
174+
*) echo "::error::$SHA is not on main (compare status: $STATUS); only commits on main are packaged"; exit 1 ;;
175+
esac
176+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
177+
178+
- name: Set up Docker Buildx
179+
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
180+
181+
- name: Log in to ghcr.io
182+
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
183+
with:
184+
registry: ghcr.io
185+
username: ${{ github.actor }}
186+
password: ${{ secrets.GITHUB_TOKEN }}
187+
188+
- name: Is this commit already packaged
189+
id: existing
190+
env:
191+
REF: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }}
192+
REBUILD: ${{ inputs.rebuild && 'true' || 'false' }}
193+
run: |
194+
set -euo pipefail
195+
DIGEST="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true)"
196+
if [ -n "$DIGEST" ] && [ "$REBUILD" != true ]; then
197+
echo "$REF already exists at $DIGEST; nothing to build (dispatch with rebuild: true to replace it)."
198+
echo "skip=true" >> "$GITHUB_OUTPUT"
199+
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
200+
else
201+
echo "skip=false" >> "$GITHUB_OUTPUT"
202+
fi
203+
204+
- name: Free runner disk
205+
if: steps.existing.outputs.skip != 'true'
206+
run: |
207+
set -euxo pipefail
208+
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
209+
sudo docker image prune -af || true
210+
df -h /
211+
212+
- name: Check out the image definition (this run's main)
213+
if: steps.existing.outputs.skip != 'true'
214+
uses: actions/checkout@v7
215+
with:
216+
path: builder
217+
persist-credentials: false
218+
sparse-checkout: docker/framework-build-base
219+
220+
- name: Check out the framework commit (the build context)
221+
if: steps.existing.outputs.skip != 'true'
222+
uses: actions/checkout@v7
223+
with:
224+
ref: ${{ steps.sha.outputs.sha }}
225+
path: objectstack
226+
persist-credentials: false
227+
228+
- name: Note the build start
229+
if: steps.existing.outputs.skip != 'true'
230+
id: clock
231+
env:
232+
SHA: ${{ steps.sha.outputs.sha }}
233+
run: |
234+
set -euo pipefail
235+
[ "$(git -C objectstack rev-parse HEAD)" = "$SHA" ] || { echo "::error::the context checkout is not $SHA"; exit 1; }
236+
echo "start=$(date +%s)" >> "$GITHUB_OUTPUT"
237+
238+
- name: Build and push
239+
if: steps.existing.outputs.skip != 'true'
240+
id: build
241+
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
242+
with:
243+
context: objectstack
244+
file: builder/docker/framework-build-base/Dockerfile
245+
platforms: linux/amd64
246+
push: true
247+
provenance: false
248+
sbom: false
249+
build-args: |
250+
FRAMEWORK_SHA=${{ steps.sha.outputs.sha }}
251+
tags: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }}
252+
253+
- name: Note the build wall
254+
if: steps.existing.outputs.skip != 'true'
255+
id: wall
256+
env:
257+
START: ${{ steps.clock.outputs.start }}
258+
run: echo "seconds=$(( $(date +%s) - START ))" >> "$GITHUB_OUTPUT"
259+
260+
# The image is for anyone to pull, so it is read back the way a stranger
261+
# would: logged out. A fresh build is pulled whole and looked inside; an
262+
# existing tag only has its manifest read, anonymously.
263+
- name: Pull the image anonymously
264+
id: pull
265+
env:
266+
SHA: ${{ steps.sha.outputs.sha }}
267+
DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
268+
FRESH: ${{ steps.existing.outputs.skip != 'true' }}
269+
run: |
270+
set -euo pipefail
271+
docker logout ghcr.io
272+
REF="$IMAGE@$DIGEST"
273+
PRIVATE="::error::$IMAGE cannot be read anonymously. A new container package is private: the maintainer sets it Public once, in the package's settings (Danger Zone, Change visibility)."
274+
if [ "$FRESH" = true ]; then
275+
docker pull "$REF" || { echo "$PRIVATE"; exit 1; }
276+
bash builder/docker/framework-build-base/check-image.sh "$REF" "$SHA" | tee "$RUNNER_TEMP/readings.txt"
277+
else
278+
TOKEN="$(curl -fsS "https://ghcr.io/token?scope=repository:objectstack-ai/framework-build-base:pull&service=ghcr.io" | jq -r .token)" \
279+
&& curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" \
280+
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
281+
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
282+
"https://ghcr.io/v2/objectstack-ai/framework-build-base/manifests/$DIGEST" \
283+
|| { echo "$PRIVATE"; exit 1; }
284+
echo "manifest readable anonymously" > "$RUNNER_TEMP/readings.txt"
285+
fi
286+
287+
- name: Summary
288+
if: always() && steps.sha.outputs.sha != ''
289+
env:
290+
SHA: ${{ steps.sha.outputs.sha }}
291+
DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
292+
SKIPPED: ${{ steps.existing.outputs.skip }}
293+
WALL: ${{ steps.wall.outputs.seconds }}
294+
PULL: ${{ steps.pull.outcome }}
295+
run: |
296+
{
297+
echo "### $IMAGE"
298+
echo
299+
echo "- framework commit: \`$SHA\`"
300+
echo "- tag: \`$IMAGE:$SHA\`"
301+
echo "- digest: \`${DIGEST:-none}\`"
302+
if [ "$SKIPPED" = true ]; then
303+
echo "- build: skipped, the tag already existed"
304+
else
305+
echo "- build wall: ${WALL:-not reached} s"
306+
fi
307+
echo "- anonymous read: ${PULL:-not reached}"
308+
if [ -f "$RUNNER_TEMP/readings.txt" ]; then sed 's/^/- /' "$RUNNER_TEMP/readings.txt"; fi
309+
if [ -n "$DIGEST" ]; then
310+
echo
311+
echo "Pin it by digest: \`FROM $IMAGE:$SHA@$DIGEST\`"
312+
fi
313+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)