Skip to content

Commit 006a63e

Browse files
committed
ci: build the pinned objectui SHA on the PR that moves it (#4290)
`.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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0165VP1PnZDTvBrKwjC7jm3D
1 parent 05154a1 commit 006a63e

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
@@ -696,3 +710,170 @@ jobs:
696710

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