Skip to content

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md #13608

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md

feat(hooks): guard the origin/main ENUMERATION half, and record the incident in AGENTS.md #13608

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
# `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
- name: Turbo Cache
if: steps.relevant.outputs.should_run == 'true'
uses: actions/cache@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
# 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
# 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"
- name: Cache Playwright browsers
if: steps.relevant.outputs.should_run == 'true'
uses: actions/cache@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
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.
- name: Turbo Cache
if: steps.docs-changes.outputs.should_run == 'true'
uses: actions/cache@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'