Skip to content

fix(fields): one home for the datetime display convention #14664

fix(fields): one home for the datetime display convention

fix(fields): one home for the datetime display convention #14664

Workflow file for this run

name: CI
on:
push:
branches: [main, develop]
paths-ignore:
- '**/*.md'
- 'content/**'
- 'docs/**'
- 'apps/site/**'
- '.changeset/**'
# No `paths-ignore` here any more (objectui#3523, step 2). It skipped the
# whole workflow on a docs-only / changeset-only PR, so every context this
# file produces was simply absent there and none of them could be made
# required. The path decision now lives inside the jobs — see the
# `Decide whether this change needs a full run` step in `type-check`. `push`
# above deliberately keeps its copy: nothing judges a push to `main`.
pull_request:
branches: [main, develop]
# ── Merge queue (objectui#3523) ────────────────────────────────────────
# The merge queue is ENFORCED on this repository by a ruleset — a direct push
# to `main` returns 405 `Changes must be made through the merge queue`
# (measured in #3243). Until this trigger landed, not one of the repository's
# workflows subscribed `merge_group`: repo-wide `event=merge_group` runs stood
# at total_count = 0, historically. A queue with nothing subscribed to it can
# only have an EMPTY required-check set, so it rebuilt each PR on the current
# `main` and let it through without validating anything.
#
# That is not a theoretical hole; it was cashed in on 2026-08-07. #3498 landed
# a `scripts/` type gate, itself fully green, that left a TS2578 on `main`;
# #3503, #3510 and #3516 then merged between 02:11Z and 02:15Z with `Type
# Check` at conclusion=failure, and #3505 hot-fixed the result. objectstack
# went through the same frames (objectstack#6067 -> #5615).
#
# `types:` is spelled out although `checks_requested` is the ONLY activity
# type GitHub defines for `merge_group` today — the two spellings are
# equivalent right now (objectstack's `ci.yml` and `lint.yml` use the bare
# `merge_group:` form and produce queue builds normally, 3552 of them). Naming
# the type means a second activity type added later cannot silently start
# queue builds this workflow was never written for.
#
# `concurrency` below needs no merge-queue special case, and that was checked
# rather than assumed: on `merge_group` the `github.event.pull_request` half of
# the group expression is null, so the group falls back to `github.ref`, which
# on a queue build is the queue's own generation — measured on objectstack,
# `gh-readonly-queue/main/pr-6594-251e888ac9ace8226f3a8450951e5b40a0a84c2c`.
# It can collide with neither a pull-request group (a bare PR number) nor a
# push group (a commit sha since objectui#5422), so a queue build and the PR
# build it came from never cancel each other.
merge_group:
types: [checks_requested]
# ── Concurrency (objectui#5422) ───────────────────────────────────
# On `pull_request` the group is the PR number and `cancel-in-progress` cancels
# that PR's previous run. That is correct and is deliberately UNCHANGED here: a
# superseded PR push has nothing left to deliver.
#
# On `push` the group now carries `github.sha`, so every merge to `main` gets a
# group of its own and no merge can cancel another merge's run. Until #5422 the
# push branch of this expression fell back to `github.ref` — `refs/heads/main`
# for EVERY merge — so each merge cancelled the previous merge's still-running
# CI.
#
# That matters because the push lane is the only lane that runs the coverage
# gate (`test-coverage` + `coverage-report` below are push-only), and the merged
# 4-shard report is what enforces `coverage.thresholds` for a commit. Measured
# on this repository over the 64 completed push-lane runs on `main` between
# 2026-08-23T06:34Z and 2026-08-24T13:56Z: the merged report was produced for
# 20, five lost it to a red suite, and 39 — 61% — lost it to CANCELLATION.
# Over the 30 strictly consecutive push runs of 2026-08-24 alone it is 15 of 28
# completed (54%). The lane needs ~13.5 min end to end while the median
# inter-merge interval is 8.8 min, so most merges are overtaken before the gate
# can report at all.
#
# ⚠️ This has to live at WORKFLOW level, not on the coverage jobs. A job-level
# `concurrency:` only decides whether a job waits for another job in the same
# group; it grants no exemption from the workflow-level `cancel-in-progress`,
# which cancels the whole run and every job in it. A per-sha group on
# `test-coverage` alone would therefore NOT have protected it.
#
# ⛔ Not `cancel-in-progress: false` on a `github.ref` group: that does not
# serialise. With `cancel-in-progress` unset GitHub holds ONE pending run per
# group and DISCARDS the rest — measured on this repository's release lane at
# 95 of 200 runs never executing at all (objectui#5395). It would trade
# cancellation for silent dropping, which is strictly worse.
#
# The cost is the one option A named on #5422: a burst of merges no longer
# collapses into a single surviving run, so the push lane's other jobs also run
# to completion and runner minutes rise. Nothing is weakened by that — every
# merged commit now gets its own verdict, which is the only post-merge signal
# this repository has while the merge queue validates nothing (objectui#4986).
#
# Trigger by trigger:
# pull_request `github.event.pull_request.number` is set, so the chain stops
# there => ci-CI-<number>
# push the number is null and `github.event_name == 'push'` is true,
# so `&&` yields the sha => ci-CI-<sha>
# merge_group the number is null and the `push` test is false, so `&&`
# yields false and the chain falls through to `github.ref`,
# which on a queue build is the queue's own generation ref
# (`gh-readonly-queue/main/pr-<n>-<sha>`) — colliding with
# neither a PR group nor a push group, exactly as before.
concurrency:
group: >-
ci-${{ github.workflow }}-${{ github.event.pull_request.number
|| (github.event_name == 'push' && github.sha)
|| github.ref }}
cancel-in-progress: true
jobs:
changeset-check:
name: Changeset Fixed Group Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Verify all packages are in changeset fixed group
run: node scripts/check-changeset-fixed.mjs
# Types were entirely unguarded until #2911: no job ran `type-check`, and
# `turbo build` only checks types for packages whose build script happens to
# invoke `tsc` — the 22 `vite build` packages transpile without checking. A
# `maplibre-gl@6` breakage therefore sat on `main` for a day behind a green CI.
#
# Note that `pnpm type-check` alone is not a sufficient gate: turbo silently
# skips any package with no `type-check` script, so a package without one
# reads as passing. The coverage guard is what makes that impossible, and it
# runs first because it is instant and catches the structural mistake.
type-check:
name: Type Check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
# `pnpm check:i18n-drift` (below) compares the locale packs on this
# branch against the packs at its MERGE BASE with the target branch,
# so it needs history — and checkout's default is a depth-1 clone,
# where `git merge-base` has nothing to find. The gate treats an
# unresolvable base as a hard failure rather than a skip, so getting
# this wrong is a red build rather than a silent pass; it is spelled
# out here so it stays that way. Pinned by
# `scripts/__tests__/check-i18n-en-drift.test.ts`.
fetch-depth: 0
# ── Always report; run only when it matters (objectui#3523) ──────────
# This context used to be invisible on a docs-only or changeset-only pull
# request, because `on.pull_request.paths-ignore` skips the WHOLE workflow
# when every changed file matches — GitHub has no per-job path filter. No
# workflow means no check run, and a REQUIRED check that never reports
# does not fail the PR, it leaves it pending forever; in the merge queue
# it fails on the ruleset's 60-minute status-check timeout instead. So
# these contexts could not be required while the filter lived on the
# trigger, which is the second half of the #3523 P0 (#3509 measured a
# docs-only PR starting zero of them).
#
# The filter therefore moved from the trigger into the job: the job always
# runs and always reports, and the paths decide only whether the expensive
# steps execute. This is the shape the `docs` job below has used since
# #3450 — `should_run` plus a per-step `if:` — not a new mechanism.
#
# The exclusion list below IS the `paths-ignore` it replaced, unchanged,
# so which pull requests pay for a full run is exactly as before. The
# `push` trigger keeps its `paths-ignore`: branch protection and the merge
# queue judge pull requests and queue builds, never pushes to `main`, so
# filtering the push lane at the trigger costs nothing there and saves a
# full run on every docs merge.
#
# A failure inside this step means RUN, never SKIP. objectstack#4928 named
# that the filter contract, after a filter job that skipped when it could
# not tell produced a fully green, zero-gate pull request. Every gate in
# this workflow is spelled that way — including the `docs` job below, whose
# capture swallowed its own failure into an empty result until
# objectui#3723. `scripts/__tests__/merge-queue-reporting.test.ts` holds
# every `CHANGED=$(git diff …)` in this file and in `lint.yml` to the
# fail-open form, so a new gate cannot be added in the closed spelling.
- name: Decide whether this change needs a full run
id: relevant
run: |
if [ "${{ github.event_name }}" != 'pull_request' ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
exit 0
fi
if ! CHANGED=$(git diff --name-only \
'${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
. \
':(exclude,glob)**/*.md' \
':(exclude,glob)content/**' \
':(exclude,glob)docs/**' \
':(exclude,glob)apps/site/**' \
':(exclude,glob).changeset/**'); then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
exit 0
fi
if [ -n "$CHANGED" ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo "$CHANGED"
else
echo 'should_run=false' >> "$GITHUB_OUTPUT"
echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
fi
- name: Enable Corepack
if: steps.relevant.outputs.should_run == 'true'
run: corepack enable
- name: Verify pnpm version
if: steps.relevant.outputs.should_run == 'true'
run: pnpm --version
- name: Setup Node.js
if: steps.relevant.outputs.should_run == 'true'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
# Every package must be either type-checked or explicitly declared as a
# known gap. Runs before install: it only reads package.json files.
- name: Verify type-check coverage
if: steps.relevant.outputs.should_run == 'true'
run: node scripts/check-type-check-coverage.mjs
- name: Install dependencies
if: steps.relevant.outputs.should_run == 'true'
run: pnpm install --frozen-lockfile
# A bare specifier RESOLVING from a package directory does not mean that
# package declares it: the workspace root's `devDependencies` sit on the
# upward resolution path from everywhere, and on no consumer's. So
# `require.resolve('react', { paths: ['packages/core/src'] })` succeeds
# although `@object-ui/core` declares react in no field at all
# (objectui#4394), and a react import added there would typecheck, build
# and pass every local suite before breaking the published package for the
# first consumer without React installed. This gate asks the question root
# hoisting cannot answer for anyone — is it DECLARED — and its first full
# run found a live instance: `@object-ui/plugin-detail` shipping a bare
# `react-router-dom` import its manifest never mentioned. Same placement
# rationale as the steps below: it parses the sources with `typescript` and
# reads every package.json, so it needs the install but nothing built.
- name: Verify every imported package is declared by the package that ships it
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:phantom-deps
# A file inside a package that names its OWN package resolves through that
# package's `exports` map to `dist/`, and `type-check` waits on `^build`
# (the DEPENDENCIES' builds), never on the package's own — so on a cold
# cache the declarations do not exist and the file fails with TS2307. It is
# green on every machine that has ever run a build, which makes CI the only
# place it can be seen: PR #4789's first run was red on exactly this, one
# line in `packages/fields/src` (objectui#4801). Parses sources with
# `typescript` through the sibling gate's scanner, so it needs the install
# and nothing built — same placement rationale as the step above.
- name: Verify no package imports itself by name
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:self-import
# A build tsconfig that excludes tooling by FILE NAME (`*.test.ts`) stops
# the files that happen to be named that way and nothing else. The first
# shared helper added to a `__tests__/` directory is then a program input,
# and an emitting program writes it into the published `dist`. That has
# happened three times — objectui#4006 (73 `*.test.d.ts` across two
# packages), objectui#4836 (9 more, one of them an emitted module whose
# first statement imports `vitest`) and objectui#6943 (the same package as
# the first, because that fix wrote the name form) — and every one was
# found by a human, never by a gate. objectui#7212 measured the standing
# exposure: 29 published packages carried the name form with ZERO offending
# files, green because nobody had added such a helper yet. This gate reads
# `exclude` ARRAYS only: no build, no artifact, no emit model, which is what
# keeps it clear of the modelling objectui#4846 declined for the
# artifact-level gate. That gate stays as the second line of defence — it
# is the only criterion that cannot be wrong about what actually ships, and
# it lives in `published-dist-gate.yml` because it needs a full-repo build.
# Its runnable alias is deliberately NOT named anywhere in this file:
# `check-published-dist-tooling.test.ts` asserts by substring that no
# per-PR workflow mentions it, which is how the ruling that kept a
# full-repo build off the PR path is held — prose naming it would read the
# same to that scan as a step running it. Config
# reads plus the sibling gate's `typescript`-importing scanner, so it needs
# the install and nothing built, same as the two steps above.
- name: Verify published build tsconfigs exclude tooling DIRECTORIES, not just names
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:published-tsconfig-exclude
# `sideEffects` is a PUBLISHED CONTRACT, and an ARRAY form of it fails in
# the one direction nothing can witness: an INCOMPLETE array drops a
# registration inside a CONSUMER's bundle, with no error, no warning and
# exit 0 — the same failure mode as `"sideEffects": false`, only quieter.
# `@object-ui/app-shell` declares one because both simpler answers are
# measurably wrong for it (objectui#6683; `false` drops three live SDUI
# widget registrations, measured in objectui#6535). This gate re-derives
# the enumeration from the module bodies and fails when the array and the
# derivation disagree in EITHER direction — a missing registrar, or a
# stale name whose module no longer registers anything.
#
# Placed here rather than with the build steps for the same reason as the
# two above: it parses sources with `typescript` and reads package
# manifests, so it needs the install and nothing built. The artifact half
# of the same contract — do the registrations survive a real bundler —
# cannot run here at all and lives in `performance-budget.yml`, which
# builds the console.
- name: Verify every `sideEffects` array names exactly its registering modules
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:side-effects-array
# A block that WRAPS `ElementDataSourceGate` reads the spec's
# `PageComponentSchema.dataSource`; the input declaration for that key is
# emitted mechanically at the wrapping seam (`elementDataSourceBlock` ->
# `Registry.register`), per the maintainer ruling of 2026-08-29, so no
# block hand-writes it. This gate is the other half of "and cannot forget
# it": a file that starts rendering the gate without reaching the seam
# would publish an authoring surface missing the one key its own runtime
# honours, and the html tier would go back to reporting the only spelling
# that works as `unknown-prop` (objectui#6678). Source-only — it needs the
# install and nothing built.
- name: Verify every ElementDataSourceGate wrapper reaches the declaration seam
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:element-data-source-declaration
# Node's ESM resolver does not extension-search relative specifiers, so an
# extensionless `./SchemaRenderer` in a published `dist/` is unloadable
# under plain Node — `@object-ui/react`'s entry died with
# ERR_MODULE_NOT_FOUND (objectui#4538). `tsc` never rewrites specifiers, so
# for a package whose build preserves them the SOURCE specifier is the
# emitted one, and the defect can be judged without building anything.
# That is why the cheap leg runs here: it reads sources, needs only the
# install, and catches a regression the moment it is written.
#
# The expensive leg — which BUILDS and actually imports each published
# entry — is `.github/workflows/node-esm-load-gate.yml`. It is not here on
# purpose: it needs a full-repo build, which this repository deliberately
# does not do per pull request (the same trade `published-dist-gate.yml`
# records at length).
- name: Verify published ESM packages emit resolvable relative specifiers
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:esm-specifiers
# A local type/const declared under a `@objectstack/spec` export's NAME reads
# to the next agent as the spec's own definition — four such symbols had
# already drifted from the spec they claimed to be (objectstack#4115). Needs
# the install (it reads the spec's own `.d.ts`) but not the build, so it runs
# before the expensive steps.
- name: Verify spec-named symbols are derived, not hand-written
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:spec-symbols
# Each action renderer hands the runner an explicit key WHITELIST, so a new
# spec action key is silently dropped until five separate lists are edited —
# and nothing fails while they are not: the key parses, publishes and reads
# as honoured. Six instances were found by hand, one at a time
# (objectstack#6837 `bodyExtra`, #6938 `bodyShape`, objectui#3646
# `resultDialog`, objectui#4192 `label`/`description`), which is why
# objectui#4050's ruling asks for a gate rather than a seventh review. Same
# placement rationale as the step above: it reads `@objectstack/spec`'s own
# zod shapes and parses the renderers with `typescript`, so it needs the
# install but nothing built.
- name: Verify action renderers forward every key the runtime reads
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:action-forward-parity
# A field designer offering a control that writes a key `FieldSchema`
# refuses BY NAME is a save-blocking 422 (`INVALID_METADATA`) that blocks
# EVERY later save of the object, and the author cannot tell from the UI
# which key did it. It had been filed three times — objectui#4644
# `indexed`, #4687 `distance_metric`, #4676 `placeholder` — each closed
# with a per-key tombstone written AFTER the instance was found in
# production, and nothing detected the next one (objectui#5761). This gate
# compares the designers' statically declared payload shapes against the
# installed `FieldSchema`'s own accept set. Reads sources with `typescript`
# and the installed spec, so it needs the install and nothing built — same
# placement rationale as the step above. It covers a documented SUBSET of
# the write path (keys reaching the payload only via a `patchDef` spread
# are outside its reach); the boundary is stated in the script's docblock,
# and its draft-I/O half runs in the test suite as
# `object-fields-io.spec-keys.test.ts`.
- name: Verify designer field payloads declare only keys FieldSchema accepts
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:designer-field-key-parity
# lucide retires a spelling by DROPPING IT FROM the runtime `icons` record
# while keeping it as a deprecated named export. A retired name therefore
# still imports, still type-checks and still renders wherever it is used as
# a COMPONENT (`Edit === SquarePen`, `Filter === Funnel`) — and resolves to
# nothing wherever it is used as a STRING, because the string lookups read
# that record. Nothing goes red in either direction, which is why it had to
# be repaired twice, in two packages, by two cards, each leaving a LOCAL pin
# behind (objectui#5586, objectui#5622). This gate replaces those pins with
# one predicate over the population, judged by the record itself and never
# by a list of retired spellings — a list would age the moment lucide
# retires the next name, and age silently. It also RE-DISCOVERS the
# resolvers on every run: its first pass found four record-reading resolvers
# objectui#5633's hand-built table did not know about. Reads sources with
# `typescript` and the installed lucide, so it needs the install and nothing
# built — same placement rationale as the step above.
- name: Verify authored icon names are live lucide `icons` keys
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:icon-record-names
# A key a component asks `t()` for must exist in the `en` pack.
# `all-locales-key-parity.test.ts` compares packs to EACH OTHER, so ten
# packs identically missing a key is full parity and full parity is green
# — objectui#3517 lived there for months, and this gate's first full run
# found 258 more keys in the same blind spot. Same placement rationale as
# the step above: it imports `typescript` to parse the sources, so it
# needs the install, but nothing built.
- name: Verify t() call-site keys exist in the en locale pack
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:i18n-keys
# The step above and `all-locales-key-parity.test.ts` both read KEYS. When
# an `en` VALUE changes and the nine translations do not, every key-shaped
# gate stays green — objectui#3582 and objectui#3625 were eight packs each
# serving a retired sentence, the second one as idiomatic native-script
# translations that no mechanical judgement can distinguish from healthy.
# This gate judges the event instead of the state: `en` changed here, the
# translations must change here too. Needs the install (it parses the
# packs with `typescript`) and the `fetch-depth: 0` above (it diffs
# against the merge base), but nothing built.
- name: Verify changed en strings were followed by the nine translations
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:i18n-drift
# `scripts/` is not a workspace package, so `pnpm type-check` (i.e.
# `turbo run type-check`, which walks package.json `scripts`) structurally
# cannot reach it, and the coverage guard above decides coverage per
# PACKAGE so it could not see the gap either. Until objectui#3494 that
# left every file in `scripts/__tests__/` compiled by nothing — ten pin
# tests holding this workflow, `docs-links.yml`, `lint.yml` and the
# changeset guard in place, none of which the compiler had ever read. A
# pin test that `tsc` never reads can assert a contract that no longer
# type-checks and still print green (objectui#3181).
#
# Placed here, not further down: nothing in tsconfig.scripts.json's
# program imports an @object-ui/* package, so it needs the install but not
# the `^build` that `pnpm type-check` depends on — it is cheap and fails
# fast. `scripts/__tests__/scripts-type-check.test.ts` pins that premise,
# this step's presence, and its position after the install.
- name: Type-check scripts/
if: steps.relevant.outputs.should_run == 'true'
run: pnpm type-check:scripts
# ── Cache bookkeeping cannot void a recorded verdict (objectui#6577) ──
# THE ORDERING, because it is the only thing a future reader needs in
# order to judge whether this step may be touched: this job's verdict is
# recorded by the three checking steps below, the last of which is
# `Type-check repo-root vitest setup files`. EVERYTHING AFTER THAT POINT
# IS BOOKKEEPING — writing `.turbo/cache` back for the next run. It must
# never be able to discard an answer the gate has already produced.
#
# It could, and once did. On 2026-08-26 the merge-group build of #6571
# was ejected from the queue with `CI_FAILURE` while every substantive
# check passed: all 21 real steps of this job succeeded, the gate steps
# included, and then the runner-generated `Post Turbo Cache` step spent
# 13m09s (789s) inside the cache upload and was still there when this
# job's `timeout-minutes: 20` fired at 20m02s. The job went `cancelled`,
# the merge queue cannot tell `cancelled` from `failure`, and a pull
# request that had passed was dequeued — 20 minutes of head-of-line
# blocking for every lane, then a full re-run. Same shape as the
# objectui#5304 apt block in the `e2e` job below: an unbounded network
# call whose only backstop was the job ceiling, which converts a
# transient fault into a CANCELLED check — a gate that reports nothing.
#
# A transient, not a property of this workflow: the same job on the same
# PR head thirteen minutes earlier (job 98190108000) went green in 5m53s
# and its cache save took ONE SECOND. That 1s is what the bound on the
# save step at the end of this job is sized against.
#
# Why the restore/save SPLIT and not a timeout on a single cache step:
# combined `actions/cache` declares `main: dist/restore/index.js` plus
# `post: dist/save/index.js`, so the save is a step the RUNNER generates
# (`Post Turbo Cache`, step #40 in the incident) — not an authored step,
# and no workflow syntax attaches `timeout-minutes` or
# `continue-on-error` to it. Splitting moves the save into the main
# phase, where both are ordinary documented step keys. The action's own
# `save-always` deprecation text points at this same split.
#
# The restore half is deliberately left UNBOUNDED. A restore stall fails
# before any verdict exists — a gate that did not run, which is honest —
# rather than a recorded verdict discarded, and bounding it would force a
# cold check under this same 20-minute ceiling.
#
# ⛔ Raising `timeout-minutes: 20` is not the fix and was ruled out on the
# card: a larger ceiling only buys a longer hang, still ending in
# `cancelled`, and lifting a gate's ceiling weakens the gate.
- name: Restore Turbo Cache
id: turbo-cache
if: steps.relevant.outputs.should_run == 'true'
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-type-check-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-type-check-
turbo-${{ runner.os }}-
# `type-check` dependsOn `^build`, so this builds workspace dependencies
# first: the checked packages resolve their deps through built `.d.ts`.
- name: Run type-check
if: steps.relevant.outputs.should_run == 'true'
run: pnpm type-check
# The four repo-root `vitest.setup.*` files were in ZERO tsc programs
# (objectui#3515) — the same shape as `scripts/` before objectui#3494, one
# directory over. They live outside every workspace package, so
# `pnpm type-check` above cannot reach them; the root `tsconfig.json`
# includes only packages/examples/apps; and no consumer IMPORTS them (every
# one names them as a Vitest `setupFiles` runtime path string), so no gated
# program pulls them in transitively either.
#
# Placed after `pnpm type-check`, NOT next to `pnpm type-check:scripts`
# above, and the difference is load-bearing. `tsconfig.scripts.json`'s
# program imports no workspace package, so it can run straight after the
# install; this one must, because `vitest.setup.dom.tsx` side-effect-imports
# @object-ui/components, /fields, /plugin-dashboard and /plugin-grid for
# their registrations. `tsconfig.vitest-setup.json` resolves those through
# each package's own `exports.types`, i.e. its built declarations — which
# exist only after the `^build` that `pnpm type-check` depends on.
# `scripts/__tests__/vitest-setup-type-check.test.ts` pins that ordering.
#
# A direct step, deliberately: this is not a turbo task, so no cache key
# decides its verdict. `pnpm type-check` is cached by turbo and its `inputs`
# would have to hash every root file a program reads (the objectui#3513
# lesson); running this one directly sidesteps that class of half-armed
# gate entirely, exactly as `type-check:scripts` does.
- name: Type-check repo-root vitest setup files
if: steps.relevant.outputs.should_run == 'true'
run: pnpm type-check:vitest-setup
# The bookkeeping half of the split documented at the restore step above,
# placed HERE — after the last checking step — because that is exactly
# where the post phase it replaces already ran. Nothing about which
# commits this job accepts or rejects moves with it.
#
# `timeout-minutes: 5` is the bound the incident asked for: 300x the 1s a
# healthy save of this cache measured, so it cannot fire on a working
# runner, and even measured from the slowest verdict path on record
# (6m53s, the incident run) it lands roughly eight minutes clear of this
# job's 20-minute ceiling — this step can no longer reach that ceiling.
#
# `continue-on-error: true` is the other half, and without it the bound
# would only trade a `cancelled` gate for a red one. A cache that failed
# to upload costs the next run some time; it says nothing whatsoever
# about the code under test, so it must not be allowed to speak for it.
#
# Behaviour preserved, spelled out so the equivalence is checkable:
# - `cache-hit != 'true'` reproduces the combined action's own "exact
# hit on the primary key ⇒ do not save" skip.
# - the condition names no status function, so the implicit `success()`
# still applies — matching the combined action's `post-if: success()`.
# - same `path` and same `key` as the restore step above.
- name: Save Turbo Cache
if: >-
steps.relevant.outputs.should_run == 'true'
&& steps.turbo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
timeout-minutes: 5
continue-on-error: true
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-type-check-${{ github.sha }}
# PRs run the suite split across 4 runners. The suite is dominated by fixed
# per-file cost rather than by the assertions: a PR run reported 495s wall
# clock of which only 178s was `tests` — the rest was `setup` (831s
# cumulative), `import` (199s) and `environment` (156s), each paid once per
# test file because `isolate: true` re-executes the module graph per file
# (vitest.config.mts explains why isolation has to stay on). That cost shards
# near-linearly, and the fixed overhead of an extra runner here is only ~30s
# (checkout + Node + a warm-pnpm-store install), so 4 shards cut wall clock
# ~3x for ~26% more runner minutes.
#
# Vitest shards by hashing each test file's path, so `unit` and `dom` files
# interleave across shards on their own — no manual balancing needed.
test:
name: Test (shard ${{ matrix.shard }}/4)
# Coverage instrumentation (v8 adds 40-100% overhead) is skipped on PRs;
# the `test-coverage` job below runs the coverage lane on push.
#
# Written as "not push" rather than "is pull_request" (objectui#3523): the
# third event this workflow now sees is `merge_group`, and a queue build
# that runs no tests is the hole this repository just paid for. `push` is
# still excluded because the coverage lane below is the push lane: it is
# sharded the same 4 ways since objectui#5403, and its `coverage-report`
# job merges the four blob reports into ONE complete report, which is where
# the coverage thresholds are enforced (and, since objectui#5436, where the
# report is published as an artifact rather than uploaded to Codecov). PR
# and push behaviour is unchanged — only the previously impossible third
# case moves.
if: github.event_name != 'push'
runs-on: ubuntu-latest
# Bound the job so a stalled runner / non-exiting test worker fails fast and
# is retryable, instead of hanging up to GitHub's 6h default.
timeout-minutes: 20
strategy:
# Report every shard's failures, not just the first one to break.
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
# `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
# against the merge base, which a depth-1 clone cannot resolve.
fetch-depth: 0
# Always report; run only when it matters — see the full note on the
# `type-check` job above (objectui#3523). The exclusion list must stay
# identical across the gated jobs of this workflow;
# `scripts/__tests__/merge-queue-reporting.test.ts` fails if it drifts.
- name: Decide whether this change needs a full run
id: relevant
run: |
if [ "${{ github.event_name }}" != 'pull_request' ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
exit 0
fi
if ! CHANGED=$(git diff --name-only \
'${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
. \
':(exclude,glob)**/*.md' \
':(exclude,glob)content/**' \
':(exclude,glob)docs/**' \
':(exclude,glob)apps/site/**' \
':(exclude,glob).changeset/**'); then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
exit 0
fi
if [ -n "$CHANGED" ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo "$CHANGED"
else
echo 'should_run=false' >> "$GITHUB_OUTPUT"
echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
fi
- name: Enable Corepack
if: steps.relevant.outputs.should_run == 'true'
run: corepack enable
- name: Verify pnpm version
if: steps.relevant.outputs.should_run == 'true'
run: pnpm --version
- name: Setup Node.js
if: steps.relevant.outputs.should_run == 'true'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
- name: Install dependencies
if: steps.relevant.outputs.should_run == 'true'
run: pnpm install --frozen-lockfile
# Run the canonical root Vitest project once. Running `turbo run test`
# here starts one Vitest process per package; several package configs
# intentionally inherit the root monorepo project list, so CI ends up
# repeating large chunks of the suite and can starve slower plugin tests.
- name: Run tests (shard ${{ matrix.shard }}/4)
if: steps.relevant.outputs.should_run == 'true'
run: pnpm test --shard=${{ matrix.shard }}/4
# The built-artifact lane (objectui#7183). The step above resolves every
# workspace package to its `src` — that is what the root alias map is for
# — so no test in it can observe its own package's BUILT output. This is
# the only place a `dist`-importing pin runs, and `turbo run test:dist`
# carries `dependsOn: ["build"]` for the package under test, so the bundle
# exists before the pin reads it. Before this step such a pin could not be
# committed at all: it landed with no `dist` to import, which is
# NOT MEASURED rather than a red pin, and the usual repair (delete it, or
# let it skip) leaves a green suite that measures nothing.
#
# Shard 1 only, and NOT sharded itself. The four shards are independent
# runners, so running this on all four would pay for the same build four
# times to measure the same bundle; the lane is a handful of files, so it
# runs whole, here. No `timeout-minutes` change and no heavy-test
# allowlist entry: the cost is one package build in one shard.
- name: Run built-artifact pins (dist project)
if: steps.relevant.outputs.should_run == 'true' && matrix.shard == 1
run: pnpm test:dist
# Push to main/develop: the coverage lane, sharded 4 ways with a blob-report
# merge (objectui#5403). The comment that used to sit here declined to shard
# it — "nothing blocks on this job, so it isn't worth the blob report merge
# that sharding a coverage run would require". That trade was priced when the
# job was shorter. Re-measured on aa949ba2 — current `main`, and the FIRST
# commit where objectui#5402's fix is in — with both arms on the same commit
# and the same runner class (instrument: `proto-5403-shard-coverage.yml` on
# branch `claude/issue-5403-proto`, runs 32387182880 and 32387778786;
# elapsed from each job's own `started_at`/`completed_at`):
#
# 4 shards, slowest shard job 11 min 25 s / 10 min 52 s (two rounds)
# + the merge job ~1 min 15 s (13 s of it the merge itself)
# = the sharded lane ~12 min 40 s / ~12 min 07 s
# the unsharded run 39 min 51 s of tests, 40 min 19 s of job
#
# ⚠️ The unblock comment on #5403 predicted that #5402 would have made the
# unsharded arm FASTER than the 39 min 05 s measured before it. It has not:
# 39 min 51 s on this commit, which is the old figure back within run-to-run
# noise. And measured at the level the cap acts on — the JOB — it is 40 min
# 19 s, i.e. `timeout-minutes: 40` would have killed this run about twenty
# seconds before the suite finished. That is the fifth occurrence of a
# timeout that has already fired four times, and a job killed at 40 minutes
# uploads nothing at all. Unsharded, this lane is not merely slow; it no
# longer reliably reaches its own upload step.
#
# Equivalence of the merged report is the acceptance test; it is measured
# rather than assumed, and the numbers are in the pull request that made this
# change — along with what happens on a red suite, which is the state `main`
# is in on this commit.
#
# `timeout-minutes: 40` is deliberately UNCHANGED. It had already fired four
# times on the unsharded job; at a quarter of the work per runner the margin
# stops being tight on its own, and raising or lowering a cap is a separate
# decision from this one.
#
# ## Why the shard legs override the coverage thresholds, and the merge does
# ## not
#
# `coverage.thresholds` in `vitest.config.mts` is checked by the v8 provider's
# `generateReports()` on EVERY coverage run — there is no shard-awareness
# anywhere in that path (`@vitest/coverage-v8/dist/provider.js`: `if (this
# .options.thresholds) await this.reportThresholds(...)`, reached from
# `reportCoverage()` and from `mergeReports()` alike). Sharding without the
# override would therefore evaluate the whole-suite thresholds FIVE times per
# push: once per shard over a quarter of the suite, plus once on the merged
# report. Those four extra evaluations are assertions this lane has never
# made, they judge partial data, and the only verdict they can add is a false
# red. The override keeps the check at exactly ONE evaluation over the
# complete report — the shape this lane had while it was unsharded.
#
# ⚠️ It is NOT here because the shards were seen to fail: that prediction was
# measured and refuted. A shard leg run WITHOUT the override passes today
# (proto run 32387182880, "PROTO shard 1/4 WITHOUT the threshold override",
# conclusion=success, reproduced in run 32387778786) — the v8 provider reports
# only the files a run actually loaded, so one shard's percentage lands near
# the whole suite's rather than at a quarter of it.
#
# That the gate still fires on the merged report is a code path, not a
# guess: `mergeReports()` calls `generateReports(coverageMap, true)`, the
# same function that ends in `if (this.options.thresholds) await this
# .reportThresholds(...)`. ⚠️ The CI ablation that was meant to demonstrate it
# end-to-end could not: on this commit the merged blobs carry a failing shard,
# so both the enforced leg and the 99%-threshold leg go red for that reason
# and neither separates the thresholds from the failures. Said here rather
# than left as a claim the run does not actually support.
test-coverage:
name: Test (coverage shard ${{ matrix.shard }}/4)
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 40
strategy:
# Report every shard's failures, not just the first one to break — and
# the merge below refuses to publish anything unless all four are green,
# so there is nothing to save by stopping early.
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `--reporter=blob` makes the run write `.vitest-reports/blob-N-4.json`
# (the shard index is in the FILE NAME, which is what lets the four
# artifacts be downloaded into one directory below without colliding —
# measured, not assumed). The blob carries this shard's raw coverage as
# well as its test results; the coverage report proper is produced once,
# by the merge.
- name: Run tests with coverage (shard ${{ matrix.shard }}/4)
run: >-
pnpm test:coverage --reporter=blob --shard=${{ matrix.shard }}/4
--coverage.thresholds.lines=0 --coverage.thresholds.functions=0
--coverage.thresholds.branches=0 --coverage.thresholds.statements=0
# `always()`: a shard that went red is exactly when someone wants its
# blob. It holds the failing results AND the coverage this runner
# collected, and it survives a red run — which is why the `coverage`
# directory being deleted on failure (vitest's `reportOnFailure: false`,
# objectui#5402) stops mattering for this lane. `if-no-files-found: error`
# because "the reporter wrote nothing" is a real finding, not a shrug.
#
# `retention-days: 7` is a deliberate, adjustable number rather than the
# 90-day default: a blob is ~6 MB, so four per push at roughly 18 merges a
# working day is ~3 GB of steady-state storage at a week. A week is what
# makes a Monday-morning question about a Friday-evening red run
# answerable; shorten it if the storage matters more than that.
- name: Upload this shard's blob report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-blob-${{ matrix.shard }}
path: .vitest-reports/*
include-hidden-files: true
retention-days: 7
if-no-files-found: error
# The other half of objectui#5403: merge the four blob reports into ONE
# complete coverage report, publish it, and — whatever happens — say out loud
# whether this lane actually delivered for the commit.
#
# ## The Codecov upload is gone (objectui#5436, maintainer ruling 2026-08-22)
#
# `CODECOV_TOKEN` was never set on this repository, Codecov no longer accepts
# tokenless uploads, and so `Upload coverage to Codecov` failed server-side
# (`Token length: 0`, "Token required - not valid tokenless upload") on every
# push after #5403 landed. Of the three ways out, the maintainer ruled Option
# B: retire the upload rather than set the secret, and keep the merged report
# as a build artifact plus the step summary below. What is given up is the
# Codecov trend dashboard and its PR comments. What is NOT given up is the
# GATE — the thresholds have always been enforced by the merge step, never by
# Codecov, and that is untouched here. Making the upload conditional on the
# secret being present was rejected: it restores exactly the quiet green that
# #5403 removed.
#
# ## Why this job exists rather than an `if:` on one step
#
# The upload step used to carry no `if:` at all, so it inherited the implicit
# `success()` and was SKIPPED whenever the suite failed: 311 of 373 coverage
# jobs, and 120 of the 121 most recent. From outside, a lane that delivered
# nothing and a lane that delivered an unchanged number look identical —
# which is how four dark days went unnoticed while the job failed 100% of the
# time (objectui#5402). Nothing blocks on this lane, so the fix cannot be
# "block on it"; the fix is that the lane STATES its own outcome. #5436
# carries that guarantee across to the claim that survives the upload's
# retirement:
#
# this job is green ONLY when all four shards passed, all four blob reports
# arrived, they merged into one report, the configured thresholds were
# enforced over that whole merged map, and the report was published as a
# retrievable artifact — and red, with an error annotation naming which of
# those did not happen, every other time.
#
# That is a stronger claim than the one it replaces, not a weaker one. The
# old red said "the dashboard is stale"; the new red says "the coverage GATE
# did not run on this commit" — and since the shard legs override the
# thresholds to zero, this job is the only place it can run at all.
#
# ⛔ The merge is deliberately NOT unconditional. A merged report built from
# fewer than four shards would understate coverage on every file the missing
# shard exercised, and a wrong coverage number is worse than a missing one.
# So the merge and the publish run only when all four shards are green AND
# all four blobs actually arrived; everything else takes the loud path.
#
# The job keeps the name `Test (coverage)` the unsharded job had: it is the
# context that answers "did the coverage lane deliver for this commit", and
# `scripts/dependabot-merge-gate.mjs` classifies it by that name.
coverage-report:
name: Test (coverage)
needs: test-coverage
# `always()` so the verdict step below still runs when a shard failed —
# that is the entire point. The event guard keeps this lane push-only, as
# the unsharded job was.
if: always() && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Every step up to the verdict is gated on the shards having passed:
# on a red run this job does a checkout-free ~10 seconds and reports.
- name: Checkout code
if: needs.test-coverage.result == 'success'
uses: actions/checkout@v7
with:
submodules: true
- name: Enable Corepack
if: needs.test-coverage.result == 'success'
run: corepack enable
- name: Verify pnpm version
if: needs.test-coverage.result == 'success'
run: pnpm --version
- name: Setup Node.js
if: needs.test-coverage.result == 'success'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
- name: Install dependencies
if: needs.test-coverage.result == 'success'
run: pnpm install --frozen-lockfile
- name: Download every shard's blob report
if: needs.test-coverage.result == 'success'
uses: actions/download-artifact@v8
with:
pattern: coverage-blob-*
path: .vitest-reports
merge-multiple: true
# A zero is not a reading: `--merge-reports` is perfectly happy to merge
# three blobs and print a confident, wrong total. Count them first.
- name: Refuse to merge a partial set of shard reports
id: blobs
if: needs.test-coverage.result == 'success'
run: |
# `mkdir -p` first, and no `2>/dev/null` anywhere: a missing directory
# must not abort this step before it can print the annotation below,
# and a swallowed stderr is the fail-CLOSED spelling objectui#3723
# removed from this file (pinned by merge-queue-reporting.test.ts).
mkdir -p .vitest-reports
ls -la .vitest-reports/
blobs=$(find .vitest-reports -maxdepth 1 -type f -name 'blob-*-4.json' | wc -l)
if [ "$blobs" -ne 4 ]; then
echo "::error title=Coverage merge::expected 4 shard blob reports, found $blobs — a merged report built from a partial set understates coverage, refusing to merge"
exit 1
fi
echo "all 4 shard blob reports present"
# No tests run here: `--merge-reports` reads the blobs and produces the
# one complete report. It is ALSO the gate. The configured
# `coverage.thresholds` (vitest.config.mts: lines 40, functions 33,
# branches 30, statements 40) are enforced HERE, over the whole merged
# map, and a breach exits this step non-zero — the shard legs override
# them to zero, so this step is the only place the gate runs at all.
#
# That the gate fires on a merged report is a code path, not a guess:
# `mergeReports()` calls `generateReports(coverageMap, true)`, which ends
# in `if (this.options.thresholds) await this.reportThresholds(...)`. It
# is also measured, not asserted (objectui#5436): against one real blob
# set reporting 38.4% lines, `--coverage.thresholds.lines=38` exits 0 and
# `=39` exits 1 with `ERROR: Coverage for lines (38.4%) does not meet
# global threshold (39%)`. Only the threshold moved between the two runs.
# #5403's CI ablation could not separate the two legs (its blobs carried
# a failing shard, so both went red for that reason), which is why the
# demonstration was owed here.
#
# `generateReports()` writes the json reporter BEFORE it evaluates the
# thresholds, which is what lets the publish step below still capture a
# report that breached.
- name: Merge the shard reports into one coverage report
id: merge
if: needs.test-coverage.result == 'success'
run: pnpm test:coverage --merge-reports --coverage.reporter=json --coverage.reporter=text
# What replaces the Codecov upload (objectui#5436): the merged report is
# kept here, on the run, instead of on a third-party dashboard.
#
# `!cancelled()` plus an explicit `outcome != 'skipped'`, rather than the
# implicit `success()` the steps above inherit: when the merge fails on
# the THRESHOLDS, the report exists and is precisely the artifact someone
# wants to open, so a red gate must still publish its evidence. The job
# stays red either way — the verdict step below requires `merge=success`.
#
# `retention-days: 7` matches the shard blobs deliberately: this report is
# derived from them, and outliving its own inputs would leave a number
# with nothing left to check it against. `if-no-files-found: error`
# because "the merge wrote no report" is a real finding, not a shrug.
- name: Publish the merged coverage report
id: report
if: ${{ !cancelled() && needs.test-coverage.result == 'success' && steps.merge.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage/
retention-days: 7
if-no-files-found: error
# The loud part, inherited from objectui#5403 and re-aimed at the claim
# that outlived the upload. Runs on every path — including the one where
# every step above was skipped, which without this step would leave this
# job GREEN while the coverage gate never ran.
- name: Say out loud whether the coverage gate ran on this commit
if: always()
env:
SHARDS: ${{ needs.test-coverage.result }}
BLOBS: ${{ steps.blobs.outcome }}
MERGE: ${{ steps.merge.outcome }}
REPORT: ${{ steps.report.outcome }}
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
if [ "$SHARDS" = 'success' ] && [ "$BLOBS" = 'success' ] && [ "$MERGE" = 'success' ] && [ "$REPORT" = 'success' ]; then
echo "::notice title=Coverage gate ran::the merged 4-shard report was produced for $GITHUB_SHA, the thresholds passed over the whole map, and the report is attached as the coverage-report artifact"
{
echo "### Coverage: the gate ran on the complete merged report"
echo
echo "- commit: \`$GITHUB_SHA\`"
echo "- shards: all 4 green, all 4 blob reports merged"
echo "- thresholds: enforced over the whole merged map, passed"
echo "- report: the \`coverage-report\` artifact on [this run]($run_url), kept 7 days"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
# The gate RAN and said no. Distinguished from the case below
# because they call for opposite actions: this one is a real coverage
# regression to fix, that one is a broken lane to repair.
#
# `report=success` is what separates them, and it is load-bearing
# rather than decorative. `generateReports()` writes the configured
# reporters in a loop and only THEN evaluates the thresholds, so a
# breach leaves a complete `coverage/` behind and the publish step
# succeeds; a merge that dies earlier (an unreadable blob, a version
# mismatch) writes no report, `if-no-files-found: error` makes the
# publish step red too, and this run falls through to the "gate did
# not run" branch below — where it belongs. Measured on the pull
# request for #5436: same blob set, `--coverage.thresholds.lines`
# moved from 38 to 39 across the report's own 38.4%, exit 0 then
# exit 1 with `ERROR: Coverage for lines (38.4%) does not meet global
# threshold (39%)`, and a byte-identical `coverage-final.json` left
# behind on BOTH legs.
if [ "$MERGE" = 'failure' ] && [ "$REPORT" = 'success' ]; then
echo "::error title=Coverage thresholds breached::shards=$SHARDS blobs=$BLOBS merge=failure report=$REPORT — the merged report for $GITHUB_SHA was produced and the configured thresholds were enforced over it, and they FAILED"
{
echo "### Coverage: the thresholds were enforced and BREACHED"
echo
echo "- commit: \`$GITHUB_SHA\`"
echo "- shards: all 4 green, all 4 blob reports merged"
echo "- thresholds: enforced over the whole merged map, **failed**"
echo "- report published: \`$REPORT\`"
echo
echo "This is a coverage regression, not a broken lane. The merged"
echo "report is the \`coverage-report\` artifact on [this run]($run_url);"
echo "the merge step's log names the metric and the numbers."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
echo "::error title=Coverage gate did NOT run::shards=$SHARDS blobs=$BLOBS merge=$MERGE report=$REPORT — no complete merged report was produced for $GITHUB_SHA, so the coverage thresholds were never evaluated on this commit"
{
echo "### Coverage: the gate did NOT run on this commit"
echo
echo "- commit: \`$GITHUB_SHA\`"
echo "- shard jobs: \`$SHARDS\`"
echo "- all 4 blobs present: \`$BLOBS\`"
echo "- merge + thresholds: \`$MERGE\`"
echo "- report published: \`$REPORT\`"
echo
echo "The thresholds are enforced on the MERGED report and nowhere"
echo "else — the shard legs override them to zero. So this is not a"
echo "missing dashboard number: this commit reached the branch with"
echo "its coverage gate unevaluated."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
# Build + E2E merged into one job: avoids artifact upload/download (~30s)
# and a duplicate `pnpm install` (~60s). This job only needs the console SPA
# artifact for Playwright; package build and package-size checks are covered
# by the Bundle Analysis workflow for package / console changes.
e2e:
name: Build & E2E
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
# `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
# against the merge base, which a depth-1 clone cannot resolve.
fetch-depth: 0
# Always report; run only when it matters — see the full note on the
# `type-check` job above (objectui#3523). The exclusion list must stay
# identical across the gated jobs of this workflow;
# `scripts/__tests__/merge-queue-reporting.test.ts` fails if it drifts.
- name: Decide whether this change needs a full run
id: relevant
run: |
if [ "${{ github.event_name }}" != 'pull_request' ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
exit 0
fi
if ! CHANGED=$(git diff --name-only \
'${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
. \
':(exclude,glob)**/*.md' \
':(exclude,glob)content/**' \
':(exclude,glob)docs/**' \
':(exclude,glob)apps/site/**' \
':(exclude,glob).changeset/**'); then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
exit 0
fi
if [ -n "$CHANGED" ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo "$CHANGED"
else
echo 'should_run=false' >> "$GITHUB_OUTPUT"
echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
fi
- name: Enable Corepack
if: steps.relevant.outputs.should_run == 'true'
run: corepack enable
- name: Verify pnpm version
if: steps.relevant.outputs.should_run == 'true'
run: pnpm --version
- name: Setup Node.js
if: steps.relevant.outputs.should_run == 'true'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
- name: Install dependencies
if: steps.relevant.outputs.should_run == 'true'
run: pnpm install --frozen-lockfile
- name: Build Console for E2E
if: steps.relevant.outputs.should_run == 'true'
# Pin VITE_BASE_PATH so the E2E suite (which mounts the SPA at
# /console/) gets an absolute-base build whose asset URLs resolve
# under that prefix. The default ('./') is correct for embedded
# deployments but breaks vite preview's SPA fallback.
#
# Use Vite directly instead of `pnpm --filter @object-ui/console build`:
# the package build runs `tsc` and `build:plugin`, which need built
# workspace declaration files. The Vite bundle aliases workspace
# packages to `src/` and is the only artifact Playwright consumes.
env:
VITE_BASE_PATH: /console/
run: pnpm --filter @object-ui/console exec vite build
- name: Verify build artifacts
if: steps.relevant.outputs.should_run == 'true'
run: |
if [ ! -f "apps/console/dist/index.html" ]; then
echo "Console build failed"
exit 1
fi
echo "Console build artifact is ready"
- name: Get Playwright version
if: steps.relevant.outputs.should_run == 'true'
id: playwright-version
run: |
# This pipeline's exit status is load-bearing: the value it produces
# becomes the Playwright browser cache key in the next step. Without
# `pipefail` a failing `pnpm list` is masked by `jq`, and `jq -r`
# prints the string `null` and exits 0 when the field is missing — so
# either failure used to yield a *successful* step and a key that had
# silently degraded to `playwright-Linux-` / `playwright-Linux-null`.
# That wrong bucket is stable, so two Playwright versions can share
# one cache entry and restore a stale browser (objectui#6231).
# The same block is duplicated verbatim in `ci.yml` and `live-e2e.yml`
# — keep them byte-identical so they stay greppable as a pair.
set -eo pipefail
if ! version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version'); then
echo "::error::Reading the @playwright/test version failed (pnpm list --json | jq). Refusing to write a Playwright browser cache key from it."
exit 1
fi
if [ -z "$version" ] || [ "$version" = "null" ]; then
echo "::error::Could not resolve the @playwright/test version (got: '${version}'). Refusing to write an empty or null version into the Playwright browser cache key."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
# THE ORDERING, carried here from the type-check split (objectui#6577,
# PR #7047) because it is what a future reader needs in order to judge
# whether these steps may be touched: the verdict is recorded by the
# checking steps; everything after them is bookkeeping, and bookkeeping
# must never discard an answer the gate already produced.
#
# Why the SPLIT rather than a timeout on one step: combined
# `actions/cache` declares `main: dist/restore/index.js` plus
# `post: dist/save/index.js`, so its save is a step the RUNNER generates
# at job end (`Post Cache Playwright browsers`). No workflow syntax
# attaches `timeout-minutes` or `continue-on-error` to a generated post
# step, so an upload stall runs the job into `timeout-minutes: 30`, the
# job goes `cancelled`, and the merge queue cannot tell `cancelled` from
# `failure` — an all-green pull request is ejected and every lane pays
# the ceiling in head-of-line blocking. Measured once, on the type-check
# cache: a 1-second save took 789s (objectui#6577). Splitting moves the
# save into the main phase, where both keys are ordinary documented step
# keys; `actions/cache`'s own `save-always` deprecation text points at
# this same split.
#
# ⛔ Raising `timeout-minutes: 30` is the ruled-out non-fix: a larger
# ceiling only buys a longer hang, still ends in `cancelled`, and lifting
# a gate's ceiling weakens the gate.
#
# The restore half is deliberately left UNBOUNDED, as at the type-check
# site: a restore stall fails BEFORE any verdict exists — a gate that did
# not run, which is honest — rather than a recorded verdict discarded.
- name: Restore Playwright browsers
if: steps.relevant.outputs.should_run == 'true'
uses: actions/cache/restore@v6
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
# ── No `apt-get` on this path any more (objectui#5304) ────────────────
# `playwright install --with-deps` / `install-deps` shell out to
# `apt-get update` as root. On 2026-08-19 that hung this job three times,
# each for the full 30-minute ceiling, with a byte-identical last line —
# `Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease
# [126 kB]` — after the runner's Azure mirror answered `Ign:` for all four
# noble indexes and apt fell back to archive.ubuntu.com:
# #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s)
# #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s, re-run)
# #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s)
# apt has no timeout covering a mirror that connects and then trickles, so
# the only backstop was `timeout-minutes: 30` — which converts a transient
# network fault into a CANCELLED check: a gate that reports nothing at all.
#
# A transient, not a property of the workflow: on #5290 the
# `Live E2E (informational)` job (95986118258) ran the same `install-deps`
# on the same commit in the same minute and went green in 3m26s. Which is
# why the call has to be bounded rather than trusted.
#
# Browser binaries come from the cache step above, never from apt
# (measured on the run that then hung: `Cache hit for:
# playwright-Linux-1.62.1`, 269 MB restored). `install` without
# `--with-deps` fetches them from Playwright's CDN and touches no mirror,
# so the cache-miss path is safe as spelled.
- name: Install Playwright browsers
if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
# The system libraries are on the `ubuntu-latest` image already — it ships
# Chrome, Edge and Firefox, whose shared-library closure is the one
# `install-deps chromium` asks for. Rather than assume that, this step
# PROVES it on every run by launching the same bundled Chromium the suite
# launches, and reaches for apt only if the launch fails — bounded, so a
# stalled mirror costs seconds instead of the job. It is not a softener:
# if no browser can be made to launch the script exits non-zero and this
# job goes red. Rationale and the incident log: the script's header;
# behaviour pinned by `scripts/__tests__/ensure-chromium-ready.test.ts`.
- name: Ensure Chromium can launch
if: steps.relevant.outputs.should_run == 'true'
run: bash scripts/ensure-chromium-ready.sh
- name: Run E2E tests
if: steps.relevant.outputs.should_run == 'true'
run: pnpm test:e2e --project=chromium
# `playwright.config.ts` selects the `github` reporter when CI is set, and
# that reporter writes annotations only — it produces NO
# `playwright-report/` directory, so this step uploaded nothing and said
# so in a warning rather than a failure: `No files were found with the
# provided path: playwright-report/` (objectui#4086). Every failure's
# actual evidence — screenshot, trace and `error-context.md` — is written
# by the `use.screenshot` / `use.trace` settings into `test-results/`
# instead, which is why a red E2E job left nothing behind to diagnose and
# #4086 had to be reproduced from scratch locally.
#
# Both paths are listed: `test-results/` is the one that exists today, and
# `playwright-report/` keeps working if the HTML reporter is ever enabled
# on CI. upload-artifact only warns when NO path matches, so the absent
# one costs nothing.
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: ${{ steps.relevant.outputs.should_run == 'true' && !cancelled() && failure() }}
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 14
# The bookkeeping half of the split documented at the restore step above,
# placed HERE — last in the job — because that is exactly where the post
# phase it replaces already ran. Nothing about which commits this job
# accepts or rejects moves with it.
#
# `timeout-minutes: 8` is DERIVED FOR THIS CACHE, not inherited from the
# type-check site's 5 (objectui#7048 fences that explicitly). This cache
# is Playwright's browser binaries — ~269 MB, two orders of magnitude
# larger than `.turbo/cache` — and upload time scales with size, so the
# bound has to be sized against a measurement of THIS artefact:
# - restores of this exact archive, same runner class: 3s (job
# 100096569775) and 3s (job 100094597323) here, 5s in `live-e2e.yml`
# (job 100097466897) ⇒ the cache service moves it at ~54-90 MB/s.
# - a save is only observable after a Playwright version bump (an exact
# key hit skips it, and `Post Cache Playwright browsers` measured 0s
# on every sampled run), so the honest save is bounded from the
# restore rather than read directly — stated plainly rather than
# dressed up as a direct measurement.
# - taking a deliberately pessimistic 20x penalty on the slowest
# observed restore gives ~1m40s for an honest save. 8 minutes is
# ~4.8x that, and demands only 0.56 MB/s sustained to finish.
# And it stays clear of the ceiling in both directions: 8 x 2 = 16 <= 30,
# so bookkeeping cannot crowd out the checking steps, whose whole verdict
# path measured 56s wall clock end to end on job 100096569775.
#
# `continue-on-error: true` is the other half, and without it the bound
# would only trade a `cancelled` gate for a red one. A cache that failed
# to upload costs the next run a re-download from Playwright's CDN — a
# path this job already takes on a miss — and says nothing whatsoever
# about the code under test, so it must not be allowed to speak for it.
#
# Behaviour preserved, spelled out so the equivalence is checkable:
# - `cache-hit != 'true'` reproduces the combined action's own "exact
# hit on the primary key ⇒ do not save" skip.
# - the condition names no status function, so the implicit `success()`
# still applies — matching the combined action's `post-if: success()`.
# - same `path` and same `key` as the restore step above.
- name: Save Playwright browsers
if: >-
steps.relevant.outputs.should_run == 'true'
&& steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
timeout-minutes: 8
continue-on-error: true
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
docs:
name: Build Docs
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
# Fails OPEN: if the diff cannot be computed the job builds the site,
# rather than reporting green having built nothing (objectstack#4928).
# Until objectui#3723 this step swallowed a failed `git diff` into an empty
# result (`2>/dev/null || echo ""`), which is indistinguishable from
# "nothing docs-related changed" — a checkout that did not fetch deep
# enough, a transient git failure or a malformed sha skipped the whole
# site build and the job still reported success. Measured against a
# fixture repository: on an unreachable base sha the old spelling yielded
# `should_run=false`, this one yields `true`; on a reachable base sha both
# agree, so which pull requests pay for a site build is unchanged.
- name: Check for docs changes
id: docs-changes
run: |
# `!= pull_request` covers `push` and `merge_group` alike
# (objectui#3523). A queue build has no `github.event.pull_request`,
# so the diff below would run on an empty revision range — and that
# case is NOT caught by failing open: git reads a bare `...` as
# `HEAD...HEAD` and exits 0 with no output (measured), so it would
# skip the site build on the last check before `main`. This early
# return is what covers it.
if [ "${{ github.event_name }}" != 'pull_request' ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Building the site.'
exit 0
fi
if ! CHANGED=$(git diff --name-only \
'${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
'apps/site/' \
'content/'); then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Could not diff against the merge base. Building the site rather than skipping silently.'
exit 0
fi
if [ -n "$CHANGED" ]; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
echo 'Docs-related files changed:'
echo "$CHANGED"
else
echo 'should_run=false' >> "$GITHUB_OUTPUT"
echo 'No docs-related files changed. Skipping the steps below; this check still reports.'
fi
- name: Enable Corepack
if: steps.docs-changes.outputs.should_run == 'true'
run: corepack enable
- name: Verify pnpm version
if: steps.docs-changes.outputs.should_run == 'true'
run: pnpm --version
- name: Setup Node.js
if: steps.docs-changes.outputs.should_run == 'true'
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
# The docs *link* check is deliberately NOT here any more — do not add it
# back. `node scripts/check-doc-links.mjs` ran in this job from #3213 /
# #3292 (PR #3450) until #3448 promoted it to its own workflow,
# `docs-links.yml`, with no path filters. The reason THEN was this
# workflow's own `paths-ignore`, which at the time listed `content/**` and
# `'**/*.md'` on both triggers: GitHub has no per-job path filter, so a
# docs-ONLY pull request never started `ci.yml` at all and the link check
# never saw the class of PR most likely to break a link. Since
# objectui#3523 step 2 that filter is gone from `pull_request` and survives
# only on `push` (objectui#3857), so the pull-request half of that
# blindness is gone — the push half is not, and this job still does not run
# on a docs-only push to `main`. objectui#4381 corrected the present tense
# this note used to carry. It stays removed rather than kept in both places
# either way: the standalone workflow's trigger set is a strict superset of
# this job's, so a copy here could only ever add a second red check for the
# same broken link, and a second place to forget.
# `scripts/__tests__/docs-links-workflow.test.ts` pins the gate to exactly
# one home.
# ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
# THE ORDERING, carried here from the type-check split (objectui#6577,
# PR #7047) because it is what a future reader needs in order to judge
# whether these steps may be touched: the verdict is recorded by the
# checking steps; everything after them is bookkeeping, and bookkeeping
# must never discard an answer the gate already produced.
#
# Why the SPLIT rather than a timeout on one step: combined
# `actions/cache` declares `main: dist/restore/index.js` plus
# `post: dist/save/index.js`, so its save is a step the RUNNER generates
# at job end (`Post Turbo Cache`). No workflow syntax attaches
# `timeout-minutes` or `continue-on-error` to a generated post step, so
# an upload stall runs the job into `timeout-minutes: 15` — the TIGHTEST
# ceiling of this workflow's three cached jobs — the job goes
# `cancelled`, and the merge queue cannot tell `cancelled` from
# `failure`. Measured once, on the type-check cache: a 1-second save took
# 789s and ejected an all-green pull request (objectui#6577).
# `actions/cache`'s own `save-always` deprecation text points at this
# same split.
#
# ⛔ Raising `timeout-minutes: 15` is the ruled-out non-fix: a larger
# ceiling only buys a longer hang, still ends in `cancelled`, and lifting
# a gate's ceiling weakens the gate.
#
# The restore half is deliberately left UNBOUNDED: a restore stall fails
# BEFORE any verdict exists — a gate that did not run, which is honest —
# rather than a recorded verdict discarded.
- name: Restore Turbo Cache
id: turbo-cache
if: steps.docs-changes.outputs.should_run == 'true'
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-docs-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-docs-
turbo-${{ runner.os }}-
- name: Install dependencies
if: steps.docs-changes.outputs.should_run == 'true'
run: pnpm install --frozen-lockfile
- name: Build Site
if: steps.docs-changes.outputs.should_run == 'true'
run: pnpm turbo run build --filter='@object-ui/site'
# The bookkeeping half of the split documented at the restore step above,
# placed HERE — after `Build Site`, the step that records this job's
# verdict — because that is exactly where the post phase it replaces
# already ran. Nothing about which commits this job accepts or rejects
# moves with it.
#
# `timeout-minutes: 5` is DERIVED FOR THIS SITE, and landing on the same
# number PR #7047 chose is a result rather than a copy — objectui#7048
# fences inheriting that 5, so here is this site's own arithmetic. This
# cache is `.turbo/cache`, and this job's own save of it measured 5s on
# 2026-09-02 (`Post Turbo Cache`, job 100094597253, a `merge_group` build
# where this job's steps actually run — on a pull request without
# `apps/site/` or `content/` changes they all skip). 5 minutes is 60x
# that, so it cannot fire on a working runner.
# Ceiling check: 5 x 2 = 10 <= 15, so bookkeeping cannot crowd out the
# checking steps — whose whole verdict path measured 4m26s on that job.
#
# `continue-on-error: true` is the other half, and without it the bound
# would only trade a `cancelled` gate for a red one. A cache that failed
# to upload costs the next run some time; it says nothing whatsoever
# about the code under test, so it must not be allowed to speak for it.
#
# Behaviour preserved, spelled out so the equivalence is checkable:
# - `cache-hit != 'true'` reproduces the combined action's own "exact
# hit on the primary key ⇒ do not save" skip. Note this is the EXACT
# hit only: a `restore-keys` prefix match leaves `cache-hit` false
# and the save still runs, which is what the combined action did.
# - the condition names no status function, so the implicit `success()`
# still applies — matching the combined action's `post-if: success()`.
# - same `path` and same `key` as the restore step above.
- name: Save Turbo Cache
if: >-
steps.docs-changes.outputs.should_run == 'true'
&& steps.turbo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
timeout-minutes: 5
continue-on-error: true
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-docs-${{ github.sha }}