From f7f132a12ef0f1d051aa2d7aabba2a6c261ac79e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 08:35:17 +0000 Subject: [PATCH 1/3] ci(docker): on-demand public framework build base image (framework-build-base) A new workflow builds ghcr.io/objectstack-ai/framework-build-base: 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 --- .github/workflows/framework-build-base.yml | 309 ++++++++++++++++++ docker/framework-build-base/Dockerfile | 147 +++++++++ .../Dockerfile.dockerignore | 19 ++ docker/framework-build-base/check-image.sh | 53 +++ 4 files changed, 528 insertions(+) create mode 100644 .github/workflows/framework-build-base.yml create mode 100644 docker/framework-build-base/Dockerfile create mode 100644 docker/framework-build-base/Dockerfile.dockerignore create mode 100755 docker/framework-build-base/check-image.sh diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml new file mode 100644 index 00000000000..f4ee7488cf1 --- /dev/null +++ b/.github/workflows/framework-build-base.yml @@ -0,0 +1,309 @@ +name: Framework Build Base Image + +# Builds and pushes ghcr.io/objectstack-ai/framework-build-base: from +# docker/framework-build-base/Dockerfile: ONE framework commit on `main`, its +# workspace dependencies installed, every package built, and the Console built +# into packages/console/dist. It is a builder stage for downstream images that +# link the framework by path and pin it by commit, so they can start FROM it +# instead of installing and building the whole framework on every cold cache. +# It is NOT the runtime image; that one is docker-publish.yml. +# +# On demand only, never per commit: +# - workflow_dispatch, from main, with `sha` = a full commit on main; +# - repository_dispatch, event type `framework-build-base`, with +# client_payload {"sha": "<40 hex>"}: the hook a downstream pin move can call; +# - pull_request touching this file or docker/framework-build-base/: the dry +# run of a change to the image definition. It builds the PR's base commit +# with the PR's Dockerfile and pushes nothing; its job has no packages grant. +# +# A tag is written once. A request for a commit whose tag already exists reports +# the existing digest and builds nothing (dispatch with `rebuild: true` to +# replace it), so a downstream can ask on every pin move at no cost. +# +# The image definition always comes from THIS run's checkout of main; only the +# build context is the requested commit. So a commit that predates this file can +# be packaged, and a commit cannot bring its own Dockerfile into a published image. +# +# Visibility: GitHub creates a new container package private on its first push, +# and no API changes that. The maintainer sets it Public once, in the package's +# settings (Danger Zone, Change visibility; it cannot be made private again). +# Until then the anonymous-pull step below is red and names that fix. + +on: + workflow_dispatch: + inputs: + sha: + description: 'Full 40-hex framework commit on main to package (e.g. a downstream .objectstack-sha)' + required: true + type: string + rebuild: + description: 'Rebuild and re-push even when the tag already exists (the digest changes)' + required: false + type: boolean + default: false + repository_dispatch: + types: [framework-build-base] + pull_request: + paths: + - '.github/workflows/framework-build-base.yml' + - 'docker/framework-build-base/**' + +permissions: + contents: read + +# One build per commit at a time; a second request for the same commit waits, +# then finds the tag and builds nothing. A PR's dry run is superseded by its +# next push. +concurrency: + group: framework-build-base-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event.client_payload.sha || inputs.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + IMAGE: ghcr.io/objectstack-ai/framework-build-base + +jobs: + verify: + name: Build framework build base (dry run, no push) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # The same build as `publish` below; see its budget note. + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Free runner disk + run: | + set -euxo pipefail + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker image prune -af || true + df -h / + + - name: Check out the image definition (this pull request) + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: builder + persist-credentials: false + sparse-checkout: docker/framework-build-base + + - name: Check out the framework commit (the pull request's base) + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: objectstack + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Note the build start + id: clock + run: echo "start=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Build (load into the runner, never push) + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: objectstack + file: builder/docker/framework-build-base/Dockerfile + platforms: linux/amd64 + push: false + load: true + provenance: false + sbom: false + build-args: | + FRAMEWORK_SHA=${{ github.event.pull_request.base.sha }} + tags: framework-build-base:dry-run + + - name: Look inside the image + env: + SHA: ${{ github.event.pull_request.base.sha }} + START: ${{ steps.clock.outputs.start }} + run: | + set -euo pipefail + BUILD_S=$(( $(date +%s) - START )) + READINGS="$(bash builder/docker/framework-build-base/check-image.sh framework-build-base:dry-run "$SHA")" + echo "$READINGS" + { + echo "### Framework build base, dry run (nothing pushed)" + echo + echo "- framework commit (PR base): \`$SHA\`" + echo "- build wall: ${BUILD_S} s" + echo "$READINGS" | sed 's/^/- /' + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Build & push ghcr.io/objectstack-ai/framework-build-base + # Trusted runs only: a dispatch from main, or a repository_dispatch (which + # always runs main's copy of this file). This `if` is the policy, not a + # security boundary: anyone who can push a branch can also edit it there. + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + # No reading of this workflow is on record yet. The same build, as a stage + # of downstream image builds on this runner class, runs about 20 minutes + # cold; the budget leaves room for a slow npm and the anonymous pull. + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - name: Resolve the framework commit + id: sha + env: + REQUESTED: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.sha || inputs.sha }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + SHA="$(printf '%s' "$REQUESTED" | tr -d '[:space:]')" + if ! printf '%s' "$SHA" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::the requested commit is not a full 40-hex lowercase sha" + exit 1 + fi + # Only commits on main are packaged: main must be ahead of the commit, + # or be it. Anything else (a branch head, a fork's pull ref) is refused. + if ! STATUS="$(gh api "repos/$REPO/compare/$SHA...main" --jq .status)"; then + echo "::error::cannot compare $SHA with main (is it a commit of $REPO?)" + exit 1 + fi + case "$STATUS" in + ahead|identical) ;; + *) echo "::error::$SHA is not on main (compare status: $STATUS); only commits on main are packaged"; exit 1 ;; + esac + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Log in to ghcr.io + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Is this commit already packaged + id: existing + env: + REF: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }} + REBUILD: ${{ inputs.rebuild && 'true' || 'false' }} + run: | + set -euo pipefail + DIGEST="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true)" + if [ -n "$DIGEST" ] && [ "$REBUILD" != true ]; then + echo "$REF already exists at $DIGEST; nothing to build (dispatch with rebuild: true to replace it)." + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Free runner disk + if: steps.existing.outputs.skip != 'true' + run: | + set -euxo pipefail + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker image prune -af || true + df -h / + + - name: Check out the image definition (this run's main) + if: steps.existing.outputs.skip != 'true' + uses: actions/checkout@v7 + with: + path: builder + persist-credentials: false + sparse-checkout: docker/framework-build-base + + - name: Check out the framework commit (the build context) + if: steps.existing.outputs.skip != 'true' + uses: actions/checkout@v7 + with: + ref: ${{ steps.sha.outputs.sha }} + path: objectstack + persist-credentials: false + + - name: Note the build start + if: steps.existing.outputs.skip != 'true' + id: clock + env: + SHA: ${{ steps.sha.outputs.sha }} + run: | + set -euo pipefail + [ "$(git -C objectstack rev-parse HEAD)" = "$SHA" ] || { echo "::error::the context checkout is not $SHA"; exit 1; } + echo "start=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Build and push + if: steps.existing.outputs.skip != 'true' + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: objectstack + file: builder/docker/framework-build-base/Dockerfile + platforms: linux/amd64 + push: true + provenance: false + sbom: false + build-args: | + FRAMEWORK_SHA=${{ steps.sha.outputs.sha }} + tags: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }} + + - name: Note the build wall + if: steps.existing.outputs.skip != 'true' + id: wall + env: + START: ${{ steps.clock.outputs.start }} + run: echo "seconds=$(( $(date +%s) - START ))" >> "$GITHUB_OUTPUT" + + # The image is for anyone to pull, so it is read back the way a stranger + # would: logged out. A fresh build is pulled whole and looked inside; an + # existing tag only has its manifest read, anonymously. + - name: Pull the image anonymously + id: pull + env: + SHA: ${{ steps.sha.outputs.sha }} + DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }} + FRESH: ${{ steps.existing.outputs.skip != 'true' }} + run: | + set -euo pipefail + docker logout ghcr.io + REF="$IMAGE@$DIGEST" + 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)." + if [ "$FRESH" = true ]; then + docker pull "$REF" || { echo "$PRIVATE"; exit 1; } + bash builder/docker/framework-build-base/check-image.sh "$REF" "$SHA" | tee "$RUNNER_TEMP/readings.txt" + else + TOKEN="$(curl -fsS "https://ghcr.io/token?scope=repository:objectstack-ai/framework-build-base:pull&service=ghcr.io" | jq -r .token)" \ + && curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + "https://ghcr.io/v2/objectstack-ai/framework-build-base/manifests/$DIGEST" \ + || { echo "$PRIVATE"; exit 1; } + echo "manifest readable anonymously" > "$RUNNER_TEMP/readings.txt" + fi + + - name: Summary + if: always() && steps.sha.outputs.sha != '' + env: + SHA: ${{ steps.sha.outputs.sha }} + DIGEST: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }} + SKIPPED: ${{ steps.existing.outputs.skip }} + WALL: ${{ steps.wall.outputs.seconds }} + PULL: ${{ steps.pull.outcome }} + run: | + { + echo "### $IMAGE" + echo + echo "- framework commit: \`$SHA\`" + echo "- tag: \`$IMAGE:$SHA\`" + echo "- digest: \`${DIGEST:-none}\`" + if [ "$SKIPPED" = true ]; then + echo "- build: skipped, the tag already existed" + else + echo "- build wall: ${WALL:-not reached} s" + fi + echo "- anonymous read: ${PULL:-not reached}" + if [ -f "$RUNNER_TEMP/readings.txt" ]; then sed 's/^/- /' "$RUNNER_TEMP/readings.txt"; fi + if [ -n "$DIGEST" ]; then + echo + echo "Pin it by digest: \`FROM $IMAGE:$SHA@$DIGEST\`" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docker/framework-build-base/Dockerfile b/docker/framework-build-base/Dockerfile new file mode 100644 index 00000000000..e2f9aabe686 --- /dev/null +++ b/docker/framework-build-base/Dockerfile @@ -0,0 +1,147 @@ +# syntax=docker/dockerfile:1 +# +# ObjectStack framework BUILD BASE image +# ghcr.io/objectstack-ai/framework-build-base: +# +# Built and pushed by .github/workflows/framework-build-base.yml, on demand, +# for one framework commit on `main`. NOT the runtime image: that one is +# ghcr.io/objectstack-ai/objectstack (docker/Dockerfile), a global npm install +# of a RELEASED @objectstack/cli. This one is a builder stage frozen at a +# commit: the framework source tree at /repo/objectstack with its workspace +# dependencies installed (dev dependencies included), every package built, and +# the Console built into packages/console/dist. +# +# Who it is for: a downstream image that consumes the framework as a pnpm +# workspace by `link:` path (`link:../../../objectstack/packages/*`) and pins it +# by commit rather than by release. It starts `FROM` this image and adds its own +# tree beside /repo/objectstack, instead of installing and building the whole +# framework (and the Console) itself on every cold cache or pin move. +# +# Build invocation (what the workflow runs; the context is a checkout of the +# commit being packaged, this file can come from another checkout): +# docker build -f /docker/framework-build-base/Dockerfile \ +# --build-arg FRAMEWORK_SHA=<40-hex sha of the checkout> +# +# Every build step below is the framework stage downstream images run today, +# with the same arguments: `pnpm install --frozen-lockfile --prod=false`, the +# typeless build of every package except docs and `ui/`, then the typed Console +# build. Keep them in step: an image built here with different arguments is not +# a drop-in for that stage. +# +# Only this repository's own tree goes in (Apache-2.0 outside `ui/`, MIT inside +# it, LICENSING.md). The image is public, so: no `.git` (it carries the +# checkout's credentials header; Dockerfile.dockerignore excludes it and the +# workflow checks out with persist-credentials: false), no build secret, no +# token in any layer. + +FROM node:22-slim + +# python3/make/g++ for native modules (the install compiles some from source), +# git for the build scripts that shell out to it. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /repo + +ENV CI=true + +# Skip .d.ts generation, as downstream image builds do: the runtime never loads +# a declaration, and the dts pass is the largest single build cost. It stays SET +# in the finished image on purpose, because the downstream stage that starts +# FROM here builds its own packages with it too. The one exception is the +# Console's build closure, which is rebuilt typed below (the Console compiles +# against those packages' dist/*.d.ts). turbo hashes OS_SKIP_DTS (turbo.json +# globalEnv), so the two passes never replay each other's outputs. +ENV OS_SKIP_DTS=1 + +# The version `packageManager` in package.json names; corepack refuses any other. +ARG PNPM_VERSION=10.31.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +# The store lives in a cache mount during the build, so it never lands in a +# layer. A downstream install that mounts the same path finds the setting. +RUN pnpm config set store-dir /pnpm/store + +COPY . /repo/objectstack/ + +# The authorable-surface deletion check in @objectstack/spec's build fails +# closed without a baseline. With no `.git` in this stage it anchors on the +# committed packages/spec/authorable-surface.base.json; assert the file arrived, +# so a context that dropped it fails here, in one second, with the reason. +RUN set -eu; \ + base=/repo/objectstack/packages/spec/authorable-surface.base.json; \ + if [ ! -f "$base" ]; then \ + echo "✗ $base is missing: the authorable-surface deletion check has no baseline" >&2; \ + echo " to anchor on in a build stage with no .git, and fails closed. Either this" >&2; \ + echo " commit predates the committed baseline, or the context excluded packages/spec." >&2; \ + exit 1; \ + fi; \ + echo "→ authorable-surface deletion-check anchor: in-tree baseline $(node -p "require('$base').baseRev")" + +WORKDIR /repo/objectstack + +# Install with the framework's own lockfile, then build every package except +# the docs site and the `ui/` workspace (the next RUN builds the part of `ui/` +# the Console needs, typed). The 12 GB heap: @objectstack/spec's gen:schema +# loads the whole schema set in one tsx worker thread, which outgrew 8 GB +# (ERR_WORKER_OUT_OF_MEMORY is a per-worker V8 limit, not an OS kill), and +# --concurrency=1 keeps that one heavy worker alone on a 16 GB runner. +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + --mount=type=cache,id=fw-turbo,target=/repo/objectstack/.turbo,sharing=locked \ + --mount=type=cache,id=fw-node-cache,target=/repo/objectstack/node_modules/.cache,sharing=locked \ + npm_config_registry=https://registry.npmjs.org \ + NODE_OPTIONS=--max-old-space-size=12288 \ + pnpm install --frozen-lockfile --prefer-offline --prod=false \ + && NODE_OPTIONS=--max-old-space-size=12288 pnpm exec turbo run build --filter='!@objectstack/docs' --filter='!./ui/**' --concurrency=1 + +# The Console, built from this tree's ui/apps/console into +# packages/console/dist (the directory the CLI serves) by the root +# `console:build` pipeline, typed (`env -u OS_SKIP_DTS`). ORDER IS +# LOAD-BEARING: it runs after the typeless build above, so the typed rebuild of +# the Console's closure is the one left on disk. +# +# Its build identity: the help menu names the commit the Console was built +# from, through VITE_APP_VERSION (the first 8 hex of FRAMEWORK_SHA). No `.git` +# reaches this stage, so Vite's git fallback has nothing to read; the value must +# be passed. turbo infers VITE_* for a Vite package, so it reaches Vite and +# moves the Console task's hash. +# +# The three VITE_SENTRY_* assignments are the levers the self-managed image +# built with, so one Console here serves every downstream image: an empty DSN, +# the kill switch off, the PII default off. On this tree's Console they are no +# longer read (client error reporting is configured at runtime), so they cost +# nothing; they stay so that a build-time DSN that ever comes back cannot ship +# silently in a customer artefact. They sit on the turbo command through `env`, +# never on a command chained after it. +# +# ui/ is removed at the end: the runtime serves packages/console/dist, and no +# package outside ui/ depends on an @object-ui/* one. +ARG FRAMEWORK_SHA +RUN --mount=type=cache,id=fw-turbo,target=/repo/objectstack/.turbo,sharing=locked \ + --mount=type=cache,id=fw-node-cache,target=/repo/objectstack/node_modules/.cache,sharing=locked \ + set -eu; \ + if ! printf '%s' "${FRAMEWORK_SHA:-}" | grep -Eq '^[0-9a-f]{40}$'; then \ + echo "✗ FRAMEWORK_SHA must be the full 40-hex commit of the build context, got '${FRAMEWORK_SHA:-}'" >&2; \ + exit 1; \ + fi; \ + env -u OS_SKIP_DTS \ + VITE_SENTRY_DSN= \ + VITE_SENTRY_ENABLED=false \ + VITE_SENTRY_SEND_DEFAULT_PII=false \ + VITE_APP_VERSION="$(printf '%.8s' "$FRAMEWORK_SHA")" \ + NODE_OPTIONS=--max-old-space-size=12288 \ + pnpm exec turbo run build:dist --filter=@objectstack/console --concurrency=1 \ + && test -f /repo/objectstack/packages/console/dist/index.html \ + && rm -rf /repo/objectstack/ui + +WORKDIR /repo + +LABEL org.opencontainers.image.source="https://github.com/objectstack-ai/objectstack" \ + org.opencontainers.image.title="ObjectStack framework build base" \ + org.opencontainers.image.description="Framework source at one commit, dependencies installed, every package and the Console built: a builder stage for images that link the framework by path" \ + org.opencontainers.image.licenses="Apache-2.0 AND MIT" \ + org.opencontainers.image.revision="${FRAMEWORK_SHA}" diff --git a/docker/framework-build-base/Dockerfile.dockerignore b/docker/framework-build-base/Dockerfile.dockerignore new file mode 100644 index 00000000000..7bb354e57b6 --- /dev/null +++ b/docker/framework-build-base/Dockerfile.dockerignore @@ -0,0 +1,19 @@ +# BuildKit ignore file for docker/framework-build-base/Dockerfile. +# +# BuildKit reads `.dockerignore` next to the Dockerfile, so +# this file applies whatever the context is. Patterns are relative to the +# CONTEXT root, which is a checkout of the commit being packaged. +# +# Keep it artifact-only: patterns match every path in the context, and a broad +# name (`**/data`, `**/build`) silently deletes source directories such as +# packages/spec/src/data. + +# The image is public, and a checkout's .git/config can carry the credentials +# header actions/checkout writes. `.git` also differs on every checkout, so +# admitting it would rehash the COPY layer on every build. +**/.git + +# Regenerated inside the image; never source. +**/node_modules +**/.pnpm-store +**/.turbo diff --git a/docker/framework-build-base/check-image.sh b/docker/framework-build-base/check-image.sh new file mode 100755 index 00000000000..0ec8ad9da3d --- /dev/null +++ b/docker/framework-build-base/check-image.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +# +# Look inside a built framework build base image and fail when it is not the +# image docker/framework-build-base/Dockerfile promises: +# +# bash docker/framework-build-base/check-image.sh <40-hex framework sha> +# +# Run by .github/workflows/framework-build-base.yml against the image it just +# built (the pull-request dry run) or just pulled anonymously (a publish). The +# image must already be in the local Docker daemon. Prints one `key=value` line +# per reading, for the job summary. +set -euo pipefail + +REF="${1:?usage: check-image.sh <40-hex framework sha>}" +SHA="${2:?usage: check-image.sh <40-hex framework sha>}" +case "$SHA" in + *[!0-9a-f]*|'') echo "✗ framework sha must be 40 lowercase hex, got '$SHA'" >&2; exit 2 ;; +esac +[ "${#SHA}" -eq 40 ] || { echo "✗ framework sha must be 40 lowercase hex, got '$SHA'" >&2; exit 2; } + +# Everything below runs inside the image, as the image's default user, with no +# network: what is asserted is what a downstream `FROM` gets. +docker run --rm --network none -e SHA8="${SHA:0:8}" "$REF" sh -eu -c ' + fw=/repo/objectstack + fail() { echo "✗ $*" >&2; exit 1; } + + # Public image: no checkout metadata (it can carry a credentials header). + [ ! -e "$fw/.git" ] || fail "$fw/.git is in the image" + [ -z "$(find /repo -name .git -print -quit)" ] || fail "a .git directory is in the image" + + # Installed and built. + [ -d "$fw/node_modules/.pnpm" ] || fail "$fw/node_modules was not installed by pnpm" + [ -f "$fw/packages/spec/dist/index.js" ] || fail "@objectstack/spec was not built" + [ -f "$fw/packages/core/dist/index.js" ] || fail "@objectstack/core was not built" + + # The Console: built into the directory the CLI serves, ui/ removed after. + dist="$fw/packages/console/dist" + [ -f "$dist/index.html" ] || fail "$dist/index.html is missing" + [ ! -e "$fw/ui" ] || fail "$fw/ui is still in the image" + # Its build identity is the first 8 hex of the framework commit. + grep -rqF "$SHA8" "$dist" || fail "no Console asset names the build identity $SHA8" + + # The posture the downstream stage relies on. + [ "${OS_SKIP_DTS:-}" = 1 ] || fail "OS_SKIP_DTS is not 1 in the image environment" + + echo "node=$(node --version)" + echo "pnpm=$(pnpm --version)" + echo "dist_dirs_under_packages=$(find "$fw/packages" -mindepth 2 -maxdepth 4 -type d -name dist -not -path "*/node_modules/*" | wc -l)" + echo "console_assets=$(find "$dist" -type f | wc -l)" +' + +echo "image_bytes=$(docker image inspect --format '{{.Size}}' "$REF")" From 0f771853ee8ba3fee58f4870fefbe9d5a3dcc6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 09:08:10 +0000 Subject: [PATCH 2/3] ci(docker): declare framework-build-base runs no check family; record 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 --- .github/workflows/framework-build-base.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index f4ee7488cf1..5049d030901 100644 --- a/.github/workflows/framework-build-base.yml +++ b/.github/workflows/framework-build-base.yml @@ -29,6 +29,8 @@ name: Framework Build Base Image # settings (Danger Zone, Change visibility; it cannot be made private again). # Until then the anonymous-pull step below is red and names that fix. +# 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 + on: workflow_dispatch: inputs: @@ -138,9 +140,11 @@ jobs: # security boundary: anyone who can push a branch can also edit it there. if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest - # No reading of this workflow is on record yet. The same build, as a stage - # of downstream image builds on this runner class, runs about 20 minutes - # cold; the budget leaves room for a slow npm and the anonymous pull. + # One cold reading on record, the `verify` job of run 42464408854: job wall + # 18m45s, of which the build 15m25s (install 33 s, 82 framework tasks + # 1m58s, 74 Console tasks 10m47s, load into the runner 94 s). This job + # skips the load and adds a push and an anonymous pull of about 0.8 GB. + # 60 minutes is about 3x, room for a slow npm or a slow registry. timeout-minutes: 60 permissions: contents: read From a54e58901b588fb32d2eb2dcf0b6cc97da8cac1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 09:19:49 +0000 Subject: [PATCH 3/3] test(pm): name the framework-build-base no-check-families declaration 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 --- scripts/pm/dispatch-gates.self-test.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index e7bfed34aed..3a1384c9440 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -8053,7 +8053,7 @@ export function selfTest({ tier = 'full' } = {}) { } const censusRows = liveMarkerCensus.map((r) => `${r.file}:${r.line} ${r.key}`).sort(); t( - `the live tree carries the ten declarations measured for this census, and no others (${censusRows.join(' · ') || 'none'})`, + `the live tree carries the eleven declarations measured for this census, and no others (${censusRows.join(' · ') || 'none'})`, censusRows.join(' · ') === [ // Seventh row, added with the declaration it names: `checklist-status.yml` // is paths-filtered (its `pull_request:` trigger is filtered to itself) and @@ -8063,6 +8063,11 @@ export function selfTest({ tier = 'full' } = {}) { // precisely so a seventh declaration reddens WITH the six already read // printed beside it, and the author adds their row instead of a number. '.github/workflows/checklist-status.yml:57 no-check-families', + // Eleventh row, added with the declaration it names: `framework-build-base.yml` + // is paths-filtered (its own file and docker/framework-build-base/) and builds + // an image, then checks it with a script that needs the built image, so it + // discovers no `check:` family. + '.github/workflows/framework-build-base.yml:32 no-check-families', '.github/workflows/merged-branch-reaper.yml:212 no-check-families', '.github/workflows/os-create-smoke.yml:48 no-check-families', '.github/workflows/scaffold-e2e.yml:23 no-check-families', @@ -8093,7 +8098,7 @@ export function selfTest({ tier = 'full' } = {}) { const censusCut = liveMarkerCensus.filter((r) => !r.whole).map((r) => `${r.file}:${r.line} ${r.key}`); t( `every live reason on those markers ENDS on its own marker line (cut: ${censusCut.join(', ') || 'none'})`, - censusCut.length === 0 && liveMarkerCensus.length === 10, + censusCut.length === 0 && liveMarkerCensus.length === 11, ); t( 'and every one of them carries a non-empty reason — whole is not the same claim as present, and both are owed',