Skip to content

fix(auth): useIsWorkspaceAdmin reads the session's published `positio… #11564

fix(auth): useIsWorkspaceAdmin reads the session's published `positio…

fix(auth): useIsWorkspaceAdmin reads the session's published `positio… #11564

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 (`refs/heads/main`), so a queue build and the PR build it came
# from never cancel each other.
merge_group:
types: [checks_requested]
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || 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
# 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 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 keeps Codecov current 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 so Codecov still receives ONE complete
# report. 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, upload that, and — whatever happens — say out
# loud whether Codecov actually received it.
#
# ## Why this job exists rather than an `if:` on the upload step
#
# `Upload coverage to Codecov` 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, "Codecov
# received nothing" and "Codecov received an unchanged report" 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. This job is
# green only when Codecov got a complete report for this commit, and red —
# with an error annotation naming the reason — every time it did not.
#
# ⛔ The upload 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 number on the dashboard is worse than a
# missing one. So the merge and the upload 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@v7
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
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 upload"
exit 1
fi
echo "all 4 shard blob reports present"
# No tests run here: `--merge-reports` reads the blobs and produces the
# single complete report Codecov gets. It is also where the configured
# coverage thresholds are enforced, over the whole map.
- name: Merge the shard reports into one coverage report
if: needs.test-coverage.result == 'success'
run: pnpm test:coverage --merge-reports --coverage.reporter=json --coverage.reporter=text
# `fail_ci_if_error: true` (it was `false`): an upload that errors is the
# same silence this card exists to remove — with `false` the step reports
# success and the dashboard quietly keeps last week's number. Nothing
# blocks on this lane, so the cost of a Codecov outage showing up red
# here is attention, which is the point.
- name: Upload coverage to Codecov
id: codecov
if: needs.test-coverage.result == 'success'
uses: codecov/codecov-action@v7
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
# The loud part. Runs on every path — including the one where every step
# above was skipped, which without this step would leave this job GREEN
# while Codecov got nothing.
- name: Say out loud whether Codecov received this commit
if: always()
env:
SHARDS: ${{ needs.test-coverage.result }}
UPLOAD: ${{ steps.codecov.outcome }}
run: |
if [ "$SHARDS" = 'success' ] && [ "$UPLOAD" = 'success' ]; then
echo "::notice title=Codecov updated::the merged 4-shard report was uploaded for $GITHUB_SHA"
{
echo "### Coverage: Codecov received a complete report"
echo
echo "- commit: \`$GITHUB_SHA\`"
echo "- shards: all 4 green, all 4 blob reports merged"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::error title=Codecov NOT updated::shards=$SHARDS upload=$UPLOAD — no report was uploaded for $GITHUB_SHA, so the coverage number on the dashboard is STALE, not unchanged"
{
echo "### Coverage: Codecov received NOTHING for this commit"
echo
echo "- commit: \`$GITHUB_SHA\`"
echo "- shard jobs: \`$SHARDS\`"
echo "- upload step: \`$UPLOAD\`"
echo
echo "The number on the Codecov dashboard is the one from an earlier"
echo "commit. It did not stay the same because coverage did not change."
} >> "$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: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].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'