Skip to content

Commit 873ecd0

Browse files
os-zhuangclaude
andauthored
ci: build the pinned objectui SHA on the PR that moves it (#4290) (#4310)
`.objectui-sha` is the single source of truth for the vendored Console SPA: release.yml reads it, clones objectui at that commit, builds `@object-ui/console` and copies the dist into `packages/console/`. Editing that one line replaces the entire frontend the platform ships — and nothing on the PR side ever built it. The pin appeared in exactly two workflows, neither a gate: release.yml (post-merge, on the main push) and showcase-smoke.yml (manual + nightly, whose own header says "it never gates PRs"). It is also a root dotfile, so it matched neither of ci.yml's `core` and `docs` filters — #4288 moved the pin 76 commits with six of fourteen checks skipped, including Build Core, Test Core, Build Docs and the Dogfood gate. A SHA that cannot build reached `main` green and would have surfaced at publish time; the only thing between the two was the author remembering to run `scripts/build-console.sh` locally. Adds ci.yml's `Console Pin Gate` — a `console` paths filter over `.objectui-sha`, `scripts/build-console.sh` and the two files that gate them, plus a job that builds `@object-ui/console` at the pin against this tree's `@objectstack/client` and then runs `pnpm check:console-sha`. Two details that keep it honest rather than merely green: - It restores `packages/console/dist` under release.yml's existing cache key (`hashFiles('.objectui-sha', 'scripts/build-console.sh')`), so only a pin the repo has never built misses — and a miss is exactly when the gate has work to do. Watching the workflow and the drift guard therefore costs about a minute, not a full vite build. - The restore/save pair is split, where release.yml uses the combined action, because the combined post-step saves even when the job failed. `build-console.sh` writes the SHA stamp before it asserts the bundle canary, so a canary failure leaves a stamped-but-broken dist that would be cached, restored, then waved through the stamp check. Saving only once every assertion is green is what lets a restored dist carry the same guarantees as a freshly built one. A separate step asserts a real dist is on disk first, since `check:console-sha` deliberately exits 0 when there is no dist at all. Scope, stated plainly: this proves the pinned SHA builds. It does not cover a `packages/client` change breaking the injected-client bundle — that input lives under `packages/**`, and watching it here would rebuild the console on a large fraction of every PR. release.yml remains the only check for that direction. Claude-Session: https://claude.ai/code/session_0165VP1PnZDTvBrKwjC7jm3D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f8170c commit 873ecd0

4 files changed

Lines changed: 242 additions & 3 deletions

File tree

.changeset/console-pin-pr-gate.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
---
3+
4+
ci: build the pinned objectui SHA on the PR that moves it (#4290)
5+
6+
`.objectui-sha` is the single source of truth for the vendored Console SPA —
7+
release.yml reads it, clones objectui at that commit, builds
8+
`@object-ui/console` and copies the dist into `packages/console/`. Editing that
9+
one line replaces the entire frontend the platform ships, and nothing on the PR
10+
side ever built it.
11+
12+
The pin appeared in exactly two workflows, neither a gate: release.yml
13+
(post-merge, on the main push) and showcase-smoke.yml (manual + nightly, whose
14+
own header says "it never gates PRs"). It is also a root dotfile, so it matched
15+
neither of ci.yml's `core` and `docs` filters — #4288 moved the pin 76 commits
16+
with six of fourteen checks skipped, including Build Core, Test Core, Build Docs
17+
and the Dogfood gate. A SHA that cannot build — a typo, a commit that was
18+
force-pushed away, a broken objectui, a dead bundle canary — reached `main`
19+
green and would have surfaced at publish time. The only thing between the two
20+
was the author remembering to run `scripts/build-console.sh` locally.
21+
22+
Adds ci.yml's `Console Pin Gate`: a `console` paths filter over `.objectui-sha`,
23+
`scripts/build-console.sh` and the two files that gate them, and a job that
24+
builds `@object-ui/console` at the pin against this tree's `@objectstack/client`
25+
and then runs `pnpm check:console-sha`.
26+
27+
Two details that keep it honest rather than merely green:
28+
29+
- It restores `packages/console/dist` under **release.yml's existing cache key**
30+
(`hashFiles('.objectui-sha', 'scripts/build-console.sh')`), so only a pin the
31+
repo has never built misses — and a miss is exactly when the gate has work to
32+
do. Watching the workflow and the drift guard therefore costs about a minute,
33+
not a full vite build.
34+
- The restore/save pair is **split**, where release.yml uses the combined
35+
action, because the combined post-step saves even when the job failed.
36+
`build-console.sh` writes the SHA stamp before it asserts the bundle canary,
37+
so a canary failure leaves a stamped-but-broken dist that would be cached,
38+
restored, and then waved through the stamp check. Saving only once every
39+
assertion is green is what lets a restored dist carry the same guarantees as a
40+
freshly built one. A separate step asserts a real dist is on disk first, since
41+
`check:console-sha` deliberately exits 0 when there is no dist at all.
42+
43+
Scope, stated plainly: this proves the pinned SHA builds. It does not cover a
44+
`packages/client` change breaking the injected-client bundle — that input lives
45+
under `packages/**`, and watching it here would rebuild the console on a large
46+
fraction of every PR. release.yml remains the only check for that direction.
47+
48+
CI and docs only; this releases nothing.

.github/workflows/ci.yml

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
outputs:
2626
docs: ${{ steps.changes.outputs.docs }}
2727
core: ${{ steps.changes.outputs.core }}
28+
console: ${{ steps.changes.outputs.console }}
2829
steps:
2930
- name: Checkout repository
3031
uses: actions/checkout@v7
@@ -46,6 +47,19 @@ jobs:
4647
- 'pnpm-lock.yaml'
4748
- 'tsconfig.json'
4849
- '.github/workflows/ci.yml'
50+
# Build inputs of the vendored Console SPA — see the Console Pin Gate
51+
# job at the bottom of this file. `.objectui-sha` is a ROOT DOTFILE, so
52+
# it matches neither filter above: a pin-only diff skipped `core` and
53+
# `docs` alike, which is how #4288 moved the pin 76 commits with six of
54+
# fourteen checks skipped and nothing anywhere building the new SHA.
55+
# The last two entries are the "a change to the guard runs the guard"
56+
# rule the repo applies to every other filtered gate; they are close to
57+
# free here because an unmoved pin hits the dist cache.
58+
console:
59+
- '.objectui-sha'
60+
- 'scripts/build-console.sh'
61+
- 'scripts/check-console-sha.mjs'
62+
- '.github/workflows/ci.yml'
4963
5064
test:
5165
name: Test Core
@@ -704,3 +718,170 @@ jobs:
704718

705719
- name: Build Docs
706720
run: pnpm --filter @objectstack/docs build
721+
722+
# ── The pinned objectui SHA actually builds (#4290) ───────────────────────
723+
#
724+
# `.objectui-sha` is the single source of truth for the vendored Console SPA:
725+
# release.yml reads it, clones objectstack-ai/objectui at that commit, builds
726+
# @object-ui/console, and copies the dist into packages/console/. Editing that
727+
# one line replaces the entire frontend the platform ships.
728+
#
729+
# Nothing on the PR side ever built it. The pin appeared in exactly two
730+
# workflows, neither of them a gate: release.yml (post-merge, on the main push)
731+
# and showcase-smoke.yml (manual + nightly, whose own header says "it never
732+
# gates PRs"). Combined with the filter hole noted above, #4288 merged a
733+
# 76-commit pin bump green with six of fourteen checks skipped. A SHA that
734+
# cannot build — a typo, a commit that was force-pushed away, a genuinely
735+
# broken objectui, a dead bundle canary — reached main unchallenged and would
736+
# have surfaced at PUBLISH time. The only thing standing between the two was
737+
# the author remembering to run scripts/build-console.sh locally: discipline,
738+
# not a gate.
739+
#
740+
# `pnpm objectui:refresh` does include the build, so the normal path was
741+
# already covered in practice; the hole is a hand-edited pin, or that single
742+
# line cherry-picked out of another branch.
743+
#
744+
# Cost is real but narrowly aimed. A moved pin is the only trigger that pays
745+
# the full clone + vite build, and #4288's 76 commits of staleness are the
746+
# measure of how rare that is. The other triggers (this file, the drift guard)
747+
# leave the pin untouched, so they hit the dist cache and cost about a minute.
748+
#
749+
# Scope, stated plainly: this proves the PINNED SHA builds. It does NOT cover a
750+
# packages/client change breaking the injected-client bundle — that input lives
751+
# under `packages/**`, and watching it here would rebuild the console on a large
752+
# fraction of every PR. release.yml stays the only check for that direction.
753+
#
754+
# The NAME is the required-check contract — the same trap the dogfood shards
755+
# note above: renaming it silently drops the gate wherever branch protection
756+
# lists it, with every PR still green.
757+
console-pin:
758+
name: Console Pin Gate
759+
needs: filter
760+
if: needs.filter.outputs.console == 'true'
761+
runs-on: ubuntu-latest
762+
timeout-minutes: 45
763+
permissions:
764+
contents: read
765+
766+
steps:
767+
- name: Checkout repository
768+
uses: actions/checkout@v7
769+
770+
- name: Setup Node.js
771+
uses: actions/setup-node@v7
772+
with:
773+
node-version: '22'
774+
775+
- name: Enable Corepack
776+
run: corepack enable
777+
778+
- name: Verify pnpm version
779+
run: pnpm --version
780+
781+
- name: Get pnpm store directory
782+
shell: bash
783+
run: |
784+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
785+
786+
- name: Setup pnpm cache
787+
uses: actions/cache@v6
788+
with:
789+
path: ${{ env.STORE_PATH }}
790+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
791+
restore-keys: |
792+
${{ runner.os }}-pnpm-store-v3-
793+
794+
# Restore-only, and with no save step at all: this job runs a handful of
795+
# times a month, so its own namespace would almost always be cold. The
796+
# build-core fallbacks below are the ones that actually hit — that job
797+
# builds a superset of the client closure needed here and is seeded from
798+
# main (same reasoning as the Temporal Conformance job's fallback).
799+
- name: Restore Turbo cache
800+
uses: actions/cache/restore@v6
801+
with:
802+
path: .turbo/cache
803+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
804+
restore-keys: |
805+
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
806+
${{ runner.os }}-turbo-${{ github.job }}-
807+
${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-
808+
${{ runner.os }}-turbo-build-core-
809+
810+
- name: Install dependencies
811+
run: pnpm install --frozen-lockfile
812+
813+
# build-console.sh bundles THIS tree's @objectstack/client into the console
814+
# (via OBJECTSTACK_CLIENT_DIST) and falls back to building it in place when
815+
# the dist is missing — but that fallback is a bare `pnpm build` inside
816+
# packages/client, which tsup cannot finish unless @objectstack/core and
817+
# @objectstack/spec are built already. Build the closure through turbo
818+
# instead: cacheable, and near-free off the restore above. Both of the
819+
# script's existing callers (release.yml, showcase-smoke.yml) pre-build for
820+
# the same reason.
821+
- name: Build @objectstack/client and its dependencies
822+
run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4
823+
824+
# Same key as release.yml's "Cache vendored Console dist" step. Actions
825+
# caches are repo-scoped, so a PR reads whatever main already built for this
826+
# pin — that is what makes the non-pin triggers cheap. Only a pin the repo
827+
# has never built misses, and a miss is exactly when this gate has work to do.
828+
#
829+
# Split restore/save where release.yml uses the combined action, because the
830+
# combined form's post-step saves even when the job FAILED. build-console.sh
831+
# writes the SHA stamp before it asserts the bundle canary, so a canary
832+
# failure leaves a stamped-but-broken dist on disk — cache that and the next
833+
# run restores it, skips the build, and sails through the stamp check. Saving
834+
# only once every assertion below is green is what lets a restored dist carry
835+
# the same guarantees as a freshly built one.
836+
- name: Restore vendored Console dist (keyed on the objectui pin)
837+
id: console-dist
838+
uses: actions/cache/restore@v6
839+
with:
840+
path: packages/console/dist
841+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
842+
843+
# The gate. Shallow-clones objectui at the pinned SHA, builds
844+
# @object-ui/console against this tree's client, copies the dist into
845+
# packages/console/, and asserts the bundle canary — every failure mode a
846+
# bad pin can carry, surfaced on the PR instead of at publish time.
847+
- name: Build the Console SPA at the pinned objectui SHA
848+
if: steps.console-dist.outputs.cache-hit != 'true'
849+
run: bash scripts/build-console.sh
850+
851+
# check:console-sha exits 0 — deliberately — when there is no dist at all
852+
# (published installs and package-only CI legitimately have none), and warns
853+
# without failing when a dist carries no stamp. Both are right for that
854+
# script and both are VACUOUS here, where producing a dist is the entire
855+
# point: an empty or partial cache restore would otherwise reach the drift
856+
# check with nothing to check and report success. Prove one is really in
857+
# place before letting that check speak for the job.
858+
- name: Assert a real Console dist is in place
859+
env:
860+
CACHE_HIT: ${{ steps.console-dist.outputs.cache-hit }}
861+
run: |
862+
dist=packages/console/dist
863+
if [ "$CACHE_HIT" = "true" ]; then origin="the dist cache restore"; else origin="scripts/build-console.sh"; fi
864+
fail=0
865+
[ -s "$dist/index.html" ] || { echo "::error::$dist/index.html is missing or empty"; fail=1; }
866+
[ -n "$(ls -A "$dist/assets" 2>/dev/null || true)" ] || { echo "::error::$dist/assets is missing or empty"; fail=1; }
867+
[ -s "$dist/.objectui-sha" ] || { echo "::error::$dist/.objectui-sha stamp is missing — check:console-sha would pass vacuously"; fail=1; }
868+
if [ "$fail" -ne 0 ]; then
869+
echo "::error::No usable Console dist after $origin."
870+
exit 1
871+
fi
872+
echo "✓ Console dist present ($(du -sk "$dist" | awk '{print $1}') KB, via $origin) from objectui@$(cut -c1-12 "$dist/.objectui-sha")"
873+
874+
- name: Verify the Console dist stamp matches the pin
875+
run: pnpm check:console-sha
876+
877+
# Reached only with every assertion above green (see the split-restore note).
878+
# This deviates from the workflow's restore-only-on-PRs policy on purpose:
879+
# that policy targets the ~5 turbo entries every push writes, whereas this
880+
# key moves only when the pin does. A pin-bump PR writes one entry, and the
881+
# rest of that PR's pushes reuse it instead of rebuilding the console again.
882+
- name: Save vendored Console dist
883+
if: steps.console-dist.outputs.cache-hit != 'true'
884+
uses: actions/cache/save@v6
885+
with:
886+
path: packages/console/dist
887+
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ jobs:
7575
# scratch on every main push (~3 min). Cache the finished dist keyed on
7676
# exactly those inputs; the stamp check below still verifies whatever
7777
# dist ends up in place, restored or fresh.
78+
#
79+
# ci.yml's Console Pin Gate (#4290) uses this exact key, so the pin bump's
80+
# PR run and this job share one build — keep the two in step if either
81+
# input set changes. That gate is now what proves the pin builds; by the
82+
# time this job runs the SHA has already merged.
7883
- name: Cache vendored Console dist (keyed on the objectui pin)
7984
id: console-dist-cache
8085
uses: actions/cache@v6

packages/console/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ keeps working.
3333
## Updating
3434

3535
1. Run `scripts/bump-objectui.sh` (or `scripts/bump-objectui.sh <sha>`) at
36-
the repo root to update `.objectui-sha`.
37-
2. CI runs `scripts/build-console.sh` before publish, which clones
36+
the repo root to update `.objectui-sha`. Prefer `pnpm objectui:refresh`,
37+
which bumps *and* rebuilds in one step.
38+
2. Opening the PR runs CI's **Console Pin Gate**, which clones objectui at
39+
the new SHA, builds `@object-ui/console` against this tree's
40+
`@objectstack/client`, and runs `pnpm check:console-sha`. A pin that
41+
cannot build fails the PR rather than the release.
42+
3. CI runs `scripts/build-console.sh` again before publish, which clones
3843
objectui at the pinned SHA, builds `@object-ui/console`, and copies
3944
`dist/` into this package.
40-
3. `pnpm publish` ships it at the same version as every other package in
45+
4. `pnpm publish` ships it at the same version as every other package in
4146
the Changesets `fixed` group.
4247

4348
The `dist/` directory is **not** committed — it's a CI publish artifact

0 commit comments

Comments
 (0)