Skip to content

feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271) #11466

feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271)

feat(lint): flow update_record 写不存在字段从盲区变为 gating error (#4271) #11466

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
# Superseded runs on the same PR/branch waste runners and delay feedback;
# cancel them. Push runs to main group by commit ref as well, so an in-flight
# main run is cancelled only by a newer main push.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
filter:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
outputs:
docs: ${{ steps.changes.outputs.docs }}
core: ${{ steps.changes.outputs.core }}
console: ${{ steps.changes.outputs.console }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: changes
with:
filters: |
docs:
- 'apps/docs/**'
- 'content/**'
- 'pnpm-lock.yaml'
- '.github/workflows/ci.yml'
core:
- 'packages/**'
- 'examples/**'
- 'apps/!(docs)/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'tsconfig.json'
- '.github/workflows/ci.yml'
# Build inputs of the vendored Console SPA — see the Console Pin Gate
# job at the bottom of this file. `.objectui-sha` is a ROOT DOTFILE, so
# it matches neither filter above: a pin-only diff skipped `core` and
# `docs` alike, which is how #4288 moved the pin 76 commits with six of
# fourteen checks skipped and nothing anywhere building the new SHA.
# The last two entries are the "a change to the guard runs the guard"
# rule the repo applies to every other filtered gate; they are close to
# free here because an unmoved pin hits the dist cache.
console:
- '.objectui-sha'
- 'scripts/build-console.sh'
- 'scripts/check-console-sha.mjs'
- '.github/workflows/ci.yml'
test:
name: Test Core
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
# Backstop only — the stall guard on the test steps is the primary
# detector for a #4250-style hang and fires well before this. 30 min is
# 2.5-3× a normal run (main ~9.5 min, PR ~12 min), with margin for a cold
# Turbo cache; the old 45 left a hung job "running" for half an hour past
# any plausible healthy finish.
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# Full history so `turbo --affected` can diff against the PR base.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only on PRs: PR-side saves (~5 turbo entries per push) churned
# the repo's 10 GB Actions cache pool and evicted the main-branch seeds —
# observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall
# back to main's entries via the prefix restore-keys; only main pushes
# save (the "Save Turbo cache" step at the end of the job).
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# PRs: only test packages affected by the diff against the PR base.
# spec sits at the root of the dependency graph, so spec-touching PRs
# still run (close to) everything — but the many PRs that don't touch
# spec skip the bulk of the 75-package matrix.
# --concurrency=4: turbo's default (10) oversubscribes the 4-vCPU
# hosted runner; matching the core count bounds peak memory and the
# job is CPU-bound anyway.
# !@objectstack/dogfood: the ~7½-minute dogfood suite is the dedicated
# Dogfood job's whole purpose, and both jobs run under the same `core`
# filter — without the exclusion every core PR executed the suite twice
# in parallel, and it dominated this job's critical path. The exclusion
# subtracts from the affected set (verified: turbo unions inclusive
# filters, then applies `!` negations to the result).
# run-with-stall-guard replaces the old `… 2>&1 | tee $log` +
# `set -o pipefail` idiom: the guard tees combined output to the log
# itself and propagates the suite's real exit status, so there is no
# pipe whose status tee could mask (do not reintroduce `| tee`). Its
# actual job is #4250: a run whose output freezes mid-suite while the
# job sits in_progress. Silence past --stall-minutes is declared a
# stall — a labeled red naming the last output line — instead of a
# 20-minute wait for a human (or the job timeout) to notice. 10 min is
# ~5× the longest healthy quiet gap and still under half a normal run.
#
# NODE_OPTIONS arms every node process (vitest workers included) to dump
# a diagnostic report on SIGUSR2; on a stall the guard signals the frozen
# process group and digests the reports into the log — exact JS stack for
# a process whose event loop is alive, and a named "no report = blocked
# loop" verdict for one that is sync-spinning. The next #4250 occurrence
# identifies its own culprit instead of costing a diagnosis.
- name: Run affected tests (PR)
if: github.event_name == 'pull_request'
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
run: |
mkdir -p "$RUNNER_TEMP/stall-reports"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
--report-dir "$RUNNER_TEMP/stall-reports" -- \
pnpm turbo run test --affected --filter=!@objectstack/dogfood --concurrency=4
# Push to main: full run. Spec's suite runs here plain (uninstrumented);
# the coverage-instrumented pass moved to the nightly Spec Coverage
# workflow (coverage-nightly.yml) — instrumentation added minutes to
# every main push for a trend artifact that is consulted occasionally at
# best. Dogfood is excluded for the same reason as the PR step: the
# Dogfood job runs it.
- name: Run all tests (push)
if: github.event_name == 'push'
env:
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
run: |
mkdir -p "$RUNNER_TEMP/stall-reports"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/test-core.log" --stall-minutes 10 \
--report-dir "$RUNNER_TEMP/stall-reports" -- \
pnpm turbo run test --filter=!@objectstack/dogfood --concurrency=4
# Runs even when the suite failed — that is when it earns its keep. A red
# suite plus a GREEN completeness check means real test failures; a red
# suite plus a RED completeness check means a worker died and the cases it
# owned never ran, which reads almost identically in the log (#3812).
- name: Test completeness guard
if: always()
run: |
if [ ! -f "$RUNNER_TEMP/test-core.log" ]; then
echo "No test log — the test step did not get far enough to produce one."
exit 0
fi
node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log"
# A stall's full diagnostic reports (JS stacks, libuv handles, heap
# summary per process) outlive the in-log digest — keep them so a #4250
# occurrence can be dissected offline. Free when nothing stalled: the
# directory is empty and if-no-files-found skips the upload.
- name: Upload stall diagnostic reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: stall-reports-test-core
path: ${{ runner.temp }}/stall-reports/
if-no-files-found: ignore
retention-days: 14
# Seed the shared Turbo cache from main only (see the restore step
# above). always(): keep the seed fresh even when a test fails, matching
# the old actions/cache post-step behavior.
- name: Save Turbo cache (main only)
if: always() && github.event_name == 'push'
uses: actions/cache/save@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
# ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ─────
#
# The datetime storage work (#3912/#3942) was verified against real servers
# because every one of its bugs was invisible on all-UTC infrastructure: a
# zone-naive write resolved in the SERVER's zone on Postgres, mysql2 rendered
# a Date in the HOST's zone, and a bare YYYY-MM-DD comparand meant a
# different midnight per dialect. The committed suites are opt-in
# (OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL) and skip without a server, so
# without this job they would never run in CI and the seam could regress
# silently — D-A3's exact concern.
#
# Every timezone here is deliberately DIFFERENT: servers at +08:00, the Node
# process at America/New_York, assertions in UTC. Both suites assert they
# are pointed at a non-UTC server, so a mis-provisioned service fails loudly
# instead of letting the job pass vacuously — and each test step now asserts
# the PROCESS zone too, which nothing did before: a dropped `TZ:` line
# silently returned the whole job to UTC coverage with everything still
# green.
#
# The job also carries the non-SQL half of that axis (core, formula,
# driver-memory, driver-mongodb, service-analytics) — the other backends the
# temporal conformance matrix holds to one standard. Its NAME still says
# "live PG + MySQL" on purpose: the name IS the required check, so renaming
# it would silently drop the gate wherever branch protection lists it — the
# same trap the dogfood shards note below.
temporal-conformance:
name: Temporal Conformance (live PG + MySQL)
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s
--health-timeout=5s
--health-retries=12
mysql:
# Real MySQL 8.0. The hands-on verification of #3942 ran on MariaDB
# 10.11 — the stricter dialect for datetime literals — so this job is
# the other half of the compatibility claim. `-h 127.0.0.1` forces the
# ping over TCP: the image's init phase runs mysqld with networking
# disabled, so a socket ping would report healthy before init finishes.
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: conformance
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=24
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Service containers cannot override the image command, so the non-UTC
# zones are set post-start through each server's own mechanism. Echoed
# back so a provisioning failure is visible in the log — though the
# suites' own non-UTC guards are the real gate.
- name: Point both servers at a non-UTC timezone
run: |
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "ALTER SYSTEM SET timezone='Asia/Shanghai'"
docker exec ${{ job.services.postgres.id }} psql -U postgres -c "SELECT pg_reload_conf()"
docker exec ${{ job.services.postgres.id }} psql -U postgres -tAc "SHOW timezone"
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -e "SET GLOBAL time_zone = '+08:00'"
docker exec ${{ job.services.mysql.id }} mysql -uroot -proot -N -e "SELECT @@global.time_zone"
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only (same policy as every other job); falls back to the Test
# Core namespace because that job builds a superset of what this one
# needs and its cache is seeded from main.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-
${{ runner.os }}-turbo-test-${{ github.ref_name }}-
${{ runner.os }}-turbo-test-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build driver-sql and its dependencies
run: pnpm exec turbo run build --filter=@objectstack/driver-sql... --concurrency=4
# The whole driver-sql suite runs under the skewed process zone — not
# just the live-server files — so a TZ-sensitive assumption anywhere in
# the driver's tests fails here before it can ship.
#
# run-with-stall-guard: same wiring as Test Core (see the comment there;
# #4250). Both of this job's test legs have hit the same nondeterministic
# stall (#4331) — once frozen mid-file in core's kernel.test, once silent
# for 24 minutes AFTER every suite printed `Done` (a process that never
# exited) — and each burned the full 30-minute job timeout to end as an
# uninformative "The operation was canceled". The guard watches output
# silence, so both shapes become a labeled red naming the last line after
# 10 quiet minutes, and its process-group kill takes down whatever
# refused to exit. `--log` is the guard's own tee (mandatory); no
# completeness guard reads these files yet. The zone-assert `node -e`
# stays outside the wrapper: a one-shot print cannot stall, and the
# guard should time the suite only.
- name: Run driver-sql suite against both live servers
env:
TZ: America/New_York
OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres
OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance
run: |
# The axis is only real if the zone actually took. Without this, a
# dropped `TZ:` line silently returns the job to UTC coverage and
# everything still passes — the same vacuous-pass hole the live-server
# suites close by asserting a non-UTC SERVER.
node -e "const tz=Intl.DateTimeFormat().resolvedOptions().timeZone,off=new Date().getTimezoneOffset();if(!tz||tz==='UTC'||off===0){console.error('process zone is '+tz+' (offset '+off+') — this job must run skewed');process.exit(1)}console.log('process zone: '+tz+' (offset '+off+')')"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/temporal-driver-sql.log" --stall-minutes 10 -- \
pnpm --filter @objectstack/driver-sql test
# The non-SQL half of the same axis. `driver-sql` has run under a skewed
# process zone since #3979, but the other backends the temporal
# conformance matrix holds to that standard never did — and they are the
# ones whose correctness rests ENTIRELY on process-zone independence,
# since they have no server to blame: `storageDatetimeValue` /
# `storageTimeValue` fold an instant through UTC getters, the filter-token
# resolver derives "today" from UTC calendar parts, and the analytics
# bucketer does the same. Swap one `getUTC*` for its local twin and every
# one of those silently shifts by the host's offset — the exact defect
# ADR-0053 D-C1 records for a `Date` bound to a Postgres TIME column.
#
# Measured before adding this: all five suites already pass under
# America/New_York (-5/-4, with DST), Asia/Kolkata (+05:30) and
# Pacific/Chatham (+12:45). So this found nothing on the day it landed,
# which is what a ratchet is for — it makes the property enforced rather
# than incidental.
- name: Build the non-SQL temporal backends
run: >-
pnpm exec turbo run build
--filter=@objectstack/service-analytics...
--filter=@objectstack/driver-memory...
--filter=@objectstack/driver-mongodb...
--filter=@objectstack/formula...
--concurrency=4
- name: Run the non-SQL temporal backends under the skewed process zone
env:
TZ: America/New_York
run: |
# The axis is only real if the zone actually took. Without this, a
# dropped `TZ:` line silently returns the job to UTC coverage and
# everything still passes — the same vacuous-pass hole the live-server
# suites close by asserting a non-UTC SERVER.
node -e "const tz=Intl.DateTimeFormat().resolvedOptions().timeZone,off=new Date().getTimezoneOffset();if(!tz||tz==='UTC'||off===0){console.error('process zone is '+tz+' (offset '+off+') — this job must run skewed');process.exit(1)}console.log('process zone: '+tz+' (offset '+off+')')"
# Stall guard: same wiring and rationale as the driver-sql step above
# (#4250/#4331) — the "silent after every suite printed Done"
# occurrence was on THIS leg.
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/temporal-non-sql.log" --stall-minutes 10 -- \
pnpm \
--filter @objectstack/core \
--filter @objectstack/formula \
--filter @objectstack/driver-memory \
--filter @objectstack/driver-mongodb \
--filter @objectstack/service-analytics \
test
dogfood:
# Sharded 2-way: the suite is ~60 independent test files, each booting its
# own in-process app, and a single 4-vCPU runner needed ~7½ minutes for the
# lot — the longest pole in the whole workflow. vitest partitions the file
# list deterministically across shards; both shards must pass. If branch
# protection lists "Dogfood Regression Gate" as a required check, it must be
# updated to the two sharded check names.
name: Dogfood Regression Gate (${{ matrix.shard }}/2)
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
# Backstop only — the stall guard on the test step is the primary detector
# for a #4250-style hang (see Test Core). 30 min is ~2.5× the slower shard
# (shard 1 runs the verify-CLI step too, ~12 min all in).
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Shard-scoped key: the turbo test hash differs per shard (pass-through
# args are part of the task hash). Restore-only on PRs — see the Restore
# Turbo cache comment in the test job; the save step at the end of this
# job seeds from main only.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Boots real example apps in-process (in-memory SQLite) and exercises them
# through the real HTTP + service stack — catches runtime regressions that
# build / unit tests / spec-liveness pass over (e.g. the #2018 tz-bucketing
# break, which was green on every static gate). The `--` args reach the
# package's `vitest run` and are hashed into the turbo task, so each
# shard caches independently.
# run-with-stall-guard: same wiring as Test Core (see the comment there;
# #4250/#4314) — it tees to dogfood.log itself for the completeness guard
# below, propagates the suite's real exit status (no `| tee` + pipefail),
# and turns frozen output into a labeled red after 10 min of silence.
# Dogfood boots real engines in-process — exactly the population #4250's
# stalls came from. NODE_OPTIONS + --report-dir arm the stall forensics
# (SIGUSR2 stack harvest) — see the Test Core comment.
- name: Boot example apps and exercise real user flows
env:
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
run: |
mkdir -p "$RUNNER_TEMP/stall-reports"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 \
--report-dir "$RUNNER_TEMP/stall-reports" -- \
pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/2
# Dogfood boots real apps in-process, so a native/OOM abort is likelier
# here than in the unit suites — and a shard that dies silently looks like
# a shard that had less work.
- name: Test completeness guard
if: always()
run: |
if [ ! -f "$RUNNER_TEMP/dogfood.log" ]; then
echo "No test log — the test step did not get far enough to produce one."
exit 0
fi
node scripts/check-test-completeness.mjs "$RUNNER_TEMP/dogfood.log"
# Same offline-forensics artifact as Test Core; shard-scoped name so the
# two matrix jobs don't collide.
- name: Upload stall diagnostic reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: stall-reports-dogfood-${{ matrix.shard }}
path: ${{ runner.temp }}/stall-reports/
if-no-files-found: ignore
retention-days: 14
# Replaces the former auto-verify dogfood tests: runs the published
# `objectstack verify` engine over each example app through the CLI —
# auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant.
# Exits non-zero on a real runtime failure, so it gates like the tests did.
# Not shard-dependent, so shard 1 alone runs it.
- name: Verify example apps via the `objectstack verify` CLI
if: matrix.shard == 1
run: |
pnpm turbo run build --filter=@objectstack/cli
for app in examples/app-crm examples/app-showcase; do
echo "::group::objectstack verify $app --rls"
OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app "$app/objectstack.config.ts" --rls
echo "::endgroup::"
done
# Seed the shared Turbo cache from main only (see the restore step
# above); shard-scoped key so the two matrix jobs don't collide.
- name: Save Turbo cache (main only)
if: always() && github.event_name == 'push'
uses: actions/cache/save@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }}
dogfood-gate:
# Stable required-check name for a SHARDED job (#3622 follow-up).
#
# Branch protection requires the context "Dogfood Regression Gate". Once
# the job became a 2-way matrix its checks publish as "Dogfood Regression
# Gate (1/2)" / "(2/2)" — the bare context could never appear again, so
# EVERY pull request in the repo sat permanently BLOCKED (mergeable, all
# checks green, merge button dead). #3622's own comment called for updating
# branch protection; keeping the contract HERE instead means a future
# shard-count change cannot deadlock the repo a second time.
#
# `if: always()` + result inspection so a legitimately skipped matrix (the
# `filter` job says no core paths changed) still satisfies the gate.
name: Dogfood Regression Gate
needs: dogfood
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Verify dogfood shard results
run: |
result="${{ needs.dogfood.result }}"
echo "dogfood matrix aggregate result: $result"
# cancelled is a run-lifecycle state, not a shard verdict (#3668):
# with cancel-in-progress on, every superseded push cancelled the
# in-flight dogfood matrix — the longest job, so almost always the
# one still running — and the old `*)` fallthrough painted a false
# red on the old SHA. Verified experimentally (run 30271824408, a
# fail-fast matrix with one real failure + one cancelled sibling):
# a real shard failure DOMINATES the aggregate — it reads "failure",
# never "cancelled" — so "cancelled" here can only mean the whole
# run was stopped from outside (supersession, or a manual cancel —
# accepted trade-off), and passing it masks no regression.
# Deliberately NOT `if: !cancelled()` on the job instead: a skipped
# gate publishes no required-check context on the SHA, which is the
# #3622 merge-deadlock all over again.
case "$result" in
success|skipped|cancelled) echo "Dogfood gate satisfied ($result)." ;;
*) echo "::error::Dogfood shards did not pass (aggregate result: $result)"; exit 1 ;;
esac
build-core:
name: Build Core
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only on PRs: PR-side saves (~5 turbo entries per push) churned
# the repo's 10 GB Actions cache pool and evicted the main-branch seeds —
# observed as sudden cold-cache spikes (Build Core 51s → 4m30s). PRs fall
# back to main's entries via the prefix restore-keys; only main pushes
# save (the "Save Turbo cache" step at the end of the job).
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages (excluding docs)
run: pnpm build
# A package that builds with plain `tsc` and does not exclude tests emits
# `dist/**/*.test.js`. Two costs, and the second is the dangerous one:
#
# 1. `files: ["dist"]` publishes the tests to npm.
# 2. A package with no vitest config COLLECTS those compiled copies
# alongside the sources, so every `src/**/*.test.ts` also runs as a
# stale `dist/**/*.test.js` built at the last `pnpm build`. That
# silently defeats edits — a fix to a source test appears not to work
# because the run is still executing the pre-fix duplicate — and it
# lets a source test be edited to pass while its stale twin keeps
# asserting the old behaviour, with neither obviously wrong.
#
# `@objectstack/cli` shipped exactly that (81 test files / 849 tests where
# its sources hold 58 / 581) until #4065 excluded them. The rest of the
# repo builds with tsup, which emits only declared entry points — so this
# gate exists to stop the NEXT tsc-built package repeating it, not to
# re-check the one that was fixed.
- name: No compiled test files in any dist
run: |
set -o pipefail
found="$(find packages -type d -name node_modules -prune -o \
-type f \( -name '*.test.js' -o -name '*.test.cjs' -o -name '*.test.mjs' \) \
-path '*/dist/*' -print | sort)"
if [ -n "$found" ]; then
echo "::error::Compiled test files found in build output. A tsc-built package is"
echo "::error::missing a test exclude in its tsconfig.build.json, so these ship to"
echo "::error::npm AND run as stale duplicates of their own sources."
echo "$found"
exit 1
fi
echo "OK — no compiled test files in any package dist."
- name: Verify build outputs
run: |
if [ ! -d "packages/spec/dist" ]; then
echo "Error: packages/spec/dist directory not found"
exit 1
fi
if [ ! -d "packages/spec/json-schema" ]; then
echo "Error: packages/spec/json-schema directory not found"
exit 1
fi
echo "Build outputs verified successfully"
# Capability packages (services / triggers / plugins) are loaded by the
# multi-tenant runtime via a DYNAMIC import of their published entry. If a
# package ships a dts-only / half-built / 0-byte `dist` (an interrupted
# build, or a package retired in source but still referenced), that import
# resolves to nothing and the capability SILENTLY fails to load — e.g.
# record-change automation never fires, with no user-visible signal. The
# build config always emits JS, so this can only happen by accident; assert
# every buildable capability package actually produced its declared runtime
# entry. Self-maintaining: reads each package's own `main`, skips dirs with
# no package.json (e.g. a retired service-feed/service-ai leftover).
- name: Verify capability packages ship a runtime entry (no dts-only / half-built)
run: |
fail=0
for d in packages/triggers/* packages/services/* packages/plugins/*; do
[ -f "$d/package.json" ] || continue
has_build=$(node -p "Boolean((require('./$d/package.json').scripts||{}).build)" 2>/dev/null || echo false)
[ "$has_build" = "true" ] || continue
main=$(node -p "require('./$d/package.json').main || 'dist/index.js'" 2>/dev/null || echo dist/index.js)
if [ ! -s "$d/$main" ]; then
echo "✗ $d: missing/empty runtime entry '$main' — dts-only or unbuilt? dynamic import would silently fail"
fail=1
fi
done
if [ "$fail" -ne 0 ]; then exit 1; fi
echo "✓ all buildable capability packages ship a runtime JS entry"
- name: Analyze bundle size
run: pnpm --filter @objectstack/spec analyze
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: build-output
path: |
packages/spec/dist/
packages/spec/json-schema/
retention-days: 30
# Seed the shared Turbo cache from main only (see the restore step above).
- name: Save Turbo cache (main only)
if: always() && github.event_name == 'push'
uses: actions/cache/save@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
build-docs:
name: Build Docs
needs: filter
if: needs.filter.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
- name: Setup Next.js cache
uses: actions/cache@v6
with:
path: apps/docs/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('apps/docs/**.[jt]s', 'apps/docs/**.[jt]sx', 'content/**/*.mdx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Docs
run: pnpm --filter @objectstack/docs build
# ── The pinned objectui SHA actually builds (#4290) ───────────────────────
#
# `.objectui-sha` is the single source of truth for the vendored Console SPA:
# release.yml reads it, clones objectstack-ai/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.
#
# Nothing on the PR side ever built it. The pin appeared in exactly two
# workflows, neither of them a gate: release.yml (post-merge, on the main push)
# and showcase-smoke.yml (manual + nightly, whose own header says "it never
# gates PRs"). Combined with the filter hole noted above, #4288 merged a
# 76-commit pin bump green with six of fourteen checks skipped. A SHA that
# cannot build — a typo, a commit that was force-pushed away, a genuinely
# broken objectui, a dead bundle canary — reached main unchallenged and would
# have surfaced at PUBLISH time. The only thing standing between the two was
# the author remembering to run scripts/build-console.sh locally: discipline,
# not a gate.
#
# `pnpm objectui:refresh` does include the build, so the normal path was
# already covered in practice; the hole is a hand-edited pin, or that single
# line cherry-picked out of another branch.
#
# Cost is real but narrowly aimed. A moved pin is the only trigger that pays
# the full clone + vite build, and #4288's 76 commits of staleness are the
# measure of how rare that is. The other triggers (this file, the drift guard)
# leave the pin untouched, so they hit the dist cache and cost about a minute.
#
# 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 stays the only check for that direction.
#
# The NAME is the required-check contract — the same trap the dogfood shards
# note above: renaming it silently drops the gate wherever branch protection
# lists it, with every PR still green.
console-pin:
name: Console Pin Gate
needs: filter
if: needs.filter.outputs.console == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only, and with no save step at all: this job runs a handful of
# times a month, so its own namespace would almost always be cold. The
# build-core fallbacks below are the ones that actually hit — that job
# builds a superset of the client closure needed here and is seeded from
# main (same reasoning as the Temporal Conformance job's fallback).
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-
${{ runner.os }}-turbo-build-core-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# build-console.sh bundles THIS tree's @objectstack/client into the console
# (via OBJECTSTACK_CLIENT_DIST) and falls back to building it in place when
# the dist is missing — but that fallback is a bare `pnpm build` inside
# packages/client, which tsup cannot finish unless @objectstack/core and
# @objectstack/spec are built already. Build the closure through turbo
# instead: cacheable, and near-free off the restore above. Both of the
# script's existing callers (release.yml, showcase-smoke.yml) pre-build for
# the same reason.
- name: Build @objectstack/client and its dependencies
run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4
# Same key as release.yml's "Cache vendored Console dist" step. Actions
# caches are repo-scoped, so a PR reads whatever main already built for this
# pin — that is what makes the non-pin triggers cheap. Only a pin the repo
# has never built misses, and a miss is exactly when this gate has work to do.
#
# Split restore/save where release.yml uses the combined action, because the
# combined form's 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 on disk — cache that and the next
# run restores it, skips the build, and sails through the stamp check. Saving
# only once every assertion below is green is what lets a restored dist carry
# the same guarantees as a freshly built one.
- name: Restore vendored Console dist (keyed on the objectui pin)
id: console-dist
uses: actions/cache/restore@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
# The gate. Shallow-clones objectui at the pinned SHA, builds
# @object-ui/console against this tree's client, copies the dist into
# packages/console/, and asserts the bundle canary — every failure mode a
# bad pin can carry, surfaced on the PR instead of at publish time.
- name: Build the Console SPA at the pinned objectui SHA
if: steps.console-dist.outputs.cache-hit != 'true'
run: bash scripts/build-console.sh
# check:console-sha exits 0 — deliberately — when there is no dist at all
# (published installs and package-only CI legitimately have none), and warns
# without failing when a dist carries no stamp. Both are right for that
# script and both are VACUOUS here, where producing a dist is the entire
# point: an empty or partial cache restore would otherwise reach the drift
# check with nothing to check and report success. Prove one is really in
# place before letting that check speak for the job.
- name: Assert a real Console dist is in place
env:
CACHE_HIT: ${{ steps.console-dist.outputs.cache-hit }}
run: |
dist=packages/console/dist
if [ "$CACHE_HIT" = "true" ]; then origin="the dist cache restore"; else origin="scripts/build-console.sh"; fi
fail=0
[ -s "$dist/index.html" ] || { echo "::error::$dist/index.html is missing or empty"; fail=1; }
[ -n "$(ls -A "$dist/assets" 2>/dev/null || true)" ] || { echo "::error::$dist/assets is missing or empty"; fail=1; }
[ -s "$dist/.objectui-sha" ] || { echo "::error::$dist/.objectui-sha stamp is missing — check:console-sha would pass vacuously"; fail=1; }
if [ "$fail" -ne 0 ]; then
echo "::error::No usable Console dist after $origin."
exit 1
fi
echo "✓ Console dist present ($(du -sk "$dist" | awk '{print $1}') KB, via $origin) from objectui@$(cut -c1-12 "$dist/.objectui-sha")"
- name: Verify the Console dist stamp matches the pin
run: pnpm check:console-sha
# Reached only with every assertion above green (see the split-restore note).
# This deviates from the workflow's restore-only-on-PRs policy on purpose:
# that policy targets the ~5 turbo entries every push writes, whereas this
# key moves only when the pin does. A pin-bump PR writes one entry, and the
# rest of that PR's pushes reuse it instead of rebuilding the console again.
- name: Save vendored Console dist
if: steps.console-dist.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: packages/console/dist
key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}