mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
c2359ba847
Adds a `Geometry tests` job to lint.yml. Without it this harness runs in no gate
at all — the workflow selects projects by name (`unit*`, `@civitai/*`, `app:*`)
and none of those patterns matches `geometry`.
ALIGNED WITH THE `unit` JOB'S COMMENT, NOT AN OVERRIDE OF IT. That comment gives
exactly one reason for keeping browser tests out — "they need Chromium, which
this job does not install. That is the whole reason." — which is a statement
about what that job provides, not a ban on providing it. The same comment then
retracts the only other objection on the record ("Don't cite a cold-cache flake
as a reason to keep this job Chromium-free") and names vitest.config.mts's
dedupe + optimizeDeps pre-bundling as the canonical fix. A job that DOES install
Chromium satisfies the stated condition.
GEOMETRY ONLY. `component` stays ungated and that is now visible rather than
fixed. Measured on a 16-core box: `geometry` is 2 files / 10 tests in 9.07s;
`component` is 212 files / 2,362 tests in 112.92s wall, of which 334s is test
time spread across workers — so a 2-core runner (browser pool `min(12, cpus-1)`
= 1 instance) does not divide it. That is an order of magnitude more expensive,
with 212 files of pass/fail history this workflow has never seen. It belongs in
its own PR.
REPORT-ONLY, mirroring `unit` and for its stated reason: blocking a brand-new
tier from day one would red unrelated PRs and the job would be switched off
within a week. `main` has no required_status_checks, so nothing here blocks a
merge either way; `continue-on-error` only decides whether a red renders as red
or as red-but-ignored. The FLIP TO BLOCKING note says concretely what would make
that safe. The `unit` job's selectors and its `continue-on-error` are untouched.
CHROMIUM comes from `pnpm exec playwright install --with-deps chromium` — the
workspace-local playwright, so the revision follows this repo's own pin rather
than a second version written into the workflow. Desynchronising those two is
the documented failure mode (CLAUDE.md records 59 preview specs dying on a
revision mismatch with zero specs run). A local NixOS bundle mismatch is a
property of that host and is handled by PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; the
pin is not moved to accommodate it.
A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the hazard the `packages` job's
ledger exists for, one project over. `--project` matching nothing exits 0, and
this tier's glob is deliberately narrow, which is exactly the kind of pattern
that can quietly stop matching. The new step asserts floors of 2 files and 10
tests from the JSON report, with `if: always()` so it also fires when the tests
fail or the runner aborts without writing a report.
Two things measured rather than assumed while writing it: the file count comes
from `testResults.length`, NOT `numTotalTestSuites` — against a real report this
run is 2 files while that field reads 4, because it counts `describe` blocks.
And the gate script was extracted back out of the parsed YAML and executed on
all three arms before commit: real report -> exit 0 (2 files, 10 tests); a
report with `testResults: []` -> exit 1; a missing report -> exit 1 with its own
message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
915 lines
50 KiB
YAML
915 lines
50 KiB
YAML
name: Lint
|
|
|
|
# Regression guard for code quality.
|
|
#
|
|
# Typecheck is full-repo, and as of talos-infra #855 it is now a NARROW job: it
|
|
# runs only where the in-cluster Tekton `pr-check` pipeline does not reach. For
|
|
# an internal PR targeting `main` — every PR merged here in living memory — the
|
|
# authoritative typecheck is the `tekton / typecheck` commit status, and this job
|
|
# skips. See the `typecheck` job comment for the two cases it still covers and
|
|
# how each was measured.
|
|
#
|
|
# 🔴 Read this before treating the fork case as covered. The job carries no
|
|
# secret and is not gated by us on the PR's origin — but GitHub gates it for us,
|
|
# and the practical outcome is that FORK PRs GET NO TYPECHECK AT ALL TODAY.
|
|
# Measured 2026-08-07 over all 16 fork-originated PRs among the ~500 most recent
|
|
# PRs: 11 have a retained `Lint` run and ALL ELEVEN sit at
|
|
# `conclusion=action_required` (awaiting maintainer approval, zero repository
|
|
# code executed); the other 5 are closed PRs whose runs have aged out. `gh pr
|
|
# checks` on the open ones reports "no checks reported". Not one fork PR has ever
|
|
# produced a Typecheck result.
|
|
#
|
|
# Two earlier revisions of this comment got this wrong in opposite directions —
|
|
# first "typecheck is skipped on fork PRs", then (correcting that) "it runs on
|
|
# fork PRs TOO". Both were reasoning from the workflow's CONFIGURATION, which
|
|
# says nothing about whether the job RUNS. The configuration genuinely does not
|
|
# gate on origin; the approval policy does. Keep the job — it is where fork
|
|
# coverage will land the moment a maintainer approves a run, and the sandbox is
|
|
# GitHub's rather than ours — but do not cite it as active fork protection.
|
|
#
|
|
# ESLint and Prettier are split by how the PR touched the file:
|
|
#
|
|
# ADDED files -> BLOCKING. A new file has no pre-existing findings by
|
|
# definition, so this is free, and it stops the backlog
|
|
# from growing while the full flip waits.
|
|
# MODIFIED/RENAMED -> report-only (`continue-on-error`). For a formatter,
|
|
# "changed file" means the WHOLE file, not the changed
|
|
# lines: 789 of 4,116 src files fail `prettier --check`
|
|
# today. Across the 98 src files touched by the last 30
|
|
# commits, 39 fail Prettier and 10 carry a pre-existing
|
|
# ESLint error - 44 (45%) would red the job for reasons
|
|
# unrelated to the PR, turning a 3-line bugfix into a
|
|
# 200-line reformat. The job would be off within a week.
|
|
#
|
|
# Findings surface as file annotations on the PR diff: ESLint via the problem
|
|
# matcher in .github/problem-matchers/, Prettier via ::warning / ::error commands.
|
|
# Report-only steps still show a failed-but-ignored marker in the checks list.
|
|
#
|
|
# The modified-file steps flip to blocking once the backlog is cleared, planned to
|
|
# ride along with the Prettier 2->3 upgrade (which forces a reformat anyway).
|
|
#
|
|
# ── ALSO RUNS ON PUSH TO `main`, and that is not decoration ──────────────────
|
|
#
|
|
# Between 2026-06-14 and 2026-08-21 NO CI of any kind produced a verdict for a
|
|
# commit on `main`. Two unrelated mechanisms happened to stop on the same day,
|
|
# which is why the gap read as one thing and stayed unexplained for two months:
|
|
#
|
|
# 1. `.github/workflows/pr-check.yml` was deleted by #2547 ("runs in Tekton,
|
|
# removes GH Actions pr-check"). It carried the only live push-to-`main`
|
|
# trigger. Enumerated rather than assumed — every revision of every one of
|
|
# the 12 files that has ever existed under `.github/workflows/` was parsed,
|
|
# and exactly TWO have ever fired on a branch push to `main`:
|
|
# `docker-deploy.yml` (removed 2023-11) and `pr-check.yml`. Two near
|
|
# misses are worth naming so they are not re-counted: `auth-app.yml` has a
|
|
# `push:` block but filters on `tags:` only, which never fires on a branch
|
|
# push, and the last `docker-deploy.yml` had `main` commented out under
|
|
# `branches:`. Everything built after the deletion — this file (#3362,
|
|
# 2026-07-24), submodule-pin-guard, schema-drift (#3643), windows-dev-env
|
|
# (#4162) — is `pull_request`-only, so the trigger was never rebuilt.
|
|
# Nobody decided that; it was lost in a move.
|
|
# 2. CodeQL, whose runs carried the "Push on main" title in the run list,
|
|
# stopped the same afternoon (code-scanning default setup now reads
|
|
# `not-configured`). Unrelated cause, same date, no test coverage either
|
|
# way — it is named here only so the next reader does not "confirm" the
|
|
# workflow deletion by pointing at a CodeQL run and vice versa.
|
|
#
|
|
# And Tekton does NOT close it. The in-cluster `pr-check` pipeline is driven by
|
|
# a resource that watches OPEN PULL REQUESTS targeting `main`; the only
|
|
# main-branch trigger is a container image build for the staging deployment,
|
|
# which runs no test suite and posts no commit status. So "Tekton covers main"
|
|
# is false, and was the assumption that let the gap persist.
|
|
#
|
|
# What that cost, concretely: a3e790ff2e was pushed straight to `main` with no
|
|
# PR, broke `model-file-scan.service.test.ts`, and sat undetected for ~7 hours.
|
|
# It surfaced only when unrelated PRs inherited the failure through their merge
|
|
# commits — every open PR going red at once, which reads as a CI outage rather
|
|
# than as one bad commit.
|
|
#
|
|
# The push path deliberately does NOT run every job here. `eslint` is diff-vs-
|
|
# base by construction and is skipped; so are the sibling workflows schema-drift
|
|
# and submodule-pin-guard, which are base-relative gates with nothing to compare
|
|
# against on a push. (`windows-dev-env` was in that list and did not belong in
|
|
# it — it is not base-relative, it just never had a push trigger. It gained one
|
|
# on 2026-08-25 to fix an Actions-cache leak; see its header.) The consequence
|
|
# is worth stating rather than leaving implied: the added-file ESLint/Prettier
|
|
# gate and the stray-migrations guard remain reachable only through a PR, so a
|
|
# direct push to `main` still bypasses them. Closing that is a separate change,
|
|
# and requires deciding what "the base" means for a push.
|
|
#
|
|
# A red run on `main` blocks nothing — the merge has already happened, and
|
|
# `main` carries no required_status_checks in any case. Its whole value is that
|
|
# it renders RED, on the commit that caused it, within minutes instead of
|
|
# whenever the next PR happens to inherit it.
|
|
|
|
on:
|
|
pull_request:
|
|
branches: [main, release]
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
# On a PR this is unchanged: `github.ref` is `refs/pull/N/merge`, so a new push
|
|
# to the branch cancels the in-flight run.
|
|
#
|
|
# On a push to `main` it MUST NOT be `github.ref` — that is the same string for
|
|
# every commit on the branch, so a busy `main` would cancel its own runs and the
|
|
# only commit with a verdict would be whichever one happened to be last. Worse,
|
|
# the losers would render as `cancelled`, a status this repo already has an open
|
|
# complaint about being indistinguishable from a real failure. Grouping by
|
|
# `github.sha` gives each commit its own group, so nothing cancels anything and
|
|
# every commit on `main` gets its own answer.
|
|
concurrency:
|
|
group: lint-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
eslint:
|
|
name: ESLint + Prettier (changed files)
|
|
runs-on: ubuntu-latest
|
|
# PULL REQUESTS ONLY, and not as a matter of taste. Every step in this job
|
|
# is defined against `FETCH_HEAD...HEAD` where FETCH_HEAD is
|
|
# `origin/$BASE_REF`, and `github.base_ref` is EMPTY on a push event — so on
|
|
# `main` the first step would run `git fetch -q origin ""` and the job would
|
|
# fail for a reason that has nothing to do with the code. "Changed files" is
|
|
# a PR concept here; a push has no base to diff against.
|
|
#
|
|
# `scripts/__tests__/main-branch-ci-coverage.test.ts` pins this as a
|
|
# RELATIONSHIP rather than as this one job's setting: any job in this file
|
|
# that reads `github.base_ref` or `github.event.pull_request` must be gated
|
|
# to pull_request events. That is the guard that catches the NEXT
|
|
# base-relative job someone adds, which would otherwise be red on every
|
|
# commit to `main` from the day it lands.
|
|
if: github.event_name == 'pull_request'
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Collect changed files
|
|
id: changed
|
|
env:
|
|
BASE_REF: ${{ github.base_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch -q origin "$BASE_REF"
|
|
git diff --name-only --diff-filter=A FETCH_HEAD...HEAD -- '*.ts' '*.tsx' > added.txt
|
|
git diff --name-only --diff-filter=CMR FETCH_HEAD...HEAD -- '*.ts' '*.tsx' > modified.txt
|
|
# Only paths an eslint config covers. apps/event-engine has its own `root: true`
|
|
# config, which eslint resolves per file, so linting it from here works; the other
|
|
# apps/ members have none and would fall through to the root Next config, whose
|
|
# typed rules crash outside src/.
|
|
LINTABLE='^(src|packages|apps/event-engine)/'
|
|
grep -E "$LINTABLE" added.txt > added-lint.txt || true
|
|
grep -E "$LINTABLE" modified.txt > modified-lint.txt || true
|
|
echo "added ($(wc -l < added.txt)):"; cat added.txt
|
|
echo "modified ($(wc -l < modified.txt)):"; cat modified.txt
|
|
|
|
# BLOCKING. Migrations live next to the schema in packages/civitai-db-schema.
|
|
# The root prisma/migrations directory predates the monorepo and has no
|
|
# migration_lock.toml, so Prisma never reads it — 68 migrations were written
|
|
# there by hand and silently ignored. `pnpm db:migrate:empty` now targets the
|
|
# right path; this catches the hand-authored case it can't.
|
|
- name: Migrations are in the schema package
|
|
run: |
|
|
set -euo pipefail
|
|
STRAYS=$(git diff --name-only --diff-filter=A FETCH_HEAD...HEAD -- 'prisma/migrations/**' || true)
|
|
if [ -n "$STRAYS" ]; then
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
echo "::error file=$f::Migrations belong in packages/civitai-db-schema/prisma/migrations/. Prisma does not read prisma/migrations/ at the repo root. Use: pnpm run db:migrate:empty \"description\""
|
|
done <<< "$STRAYS"
|
|
exit 1
|
|
fi
|
|
echo "no migrations added outside the schema package"
|
|
|
|
- name: Register ESLint problem matcher
|
|
run: echo "::add-matcher::.github/problem-matchers/eslint-unix.json"
|
|
|
|
# BLOCKING. New files start clean, so holding them to the rules costs nothing.
|
|
# Errors only (no --max-warnings): the repo has 3,470 warnings and 1,762 uses of
|
|
# `any`, so failing new files on warnings would hold them to a stricter bar than
|
|
# anything already merged. Warnings still annotate.
|
|
- name: ESLint (added files)
|
|
run: |
|
|
set -euo pipefail
|
|
if [ ! -s added-lint.txt ]; then echo "no new lintable files"; exit 0; fi
|
|
xargs -a added-lint.txt pnpm exec eslint --format unix \
|
|
| sed "s|^$GITHUB_WORKSPACE/||"
|
|
|
|
- name: Prettier (added files)
|
|
run: |
|
|
set -euo pipefail
|
|
if [ ! -s added.txt ]; then echo "no new files"; exit 0; fi
|
|
xargs -a added.txt pnpm exec prettier --list-different > unformatted-added.txt || true
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
echo "::error file=$f::Not formatted. Run: pnpm exec prettier --write $f"
|
|
done < unformatted-added.txt
|
|
if [ -s unformatted-added.txt ]; then
|
|
echo "$(wc -l < unformatted-added.txt) new file(s) are not formatted."
|
|
exit 1
|
|
fi
|
|
echo "all new files formatted"
|
|
|
|
# Report-only: 10% of recently-touched files carry a pre-existing
|
|
# error-severity finding. See the header comment.
|
|
- name: ESLint (modified files, report-only)
|
|
continue-on-error: true
|
|
run: |
|
|
set -euo pipefail
|
|
if [ ! -s modified-lint.txt ]; then echo "no modified lintable files"; exit 0; fi
|
|
xargs -a modified-lint.txt pnpm exec eslint --format unix \
|
|
| sed "s|^$GITHUB_WORKSPACE/||"
|
|
|
|
# Report-only: 19% of src files are unformatted today and a formatter has no
|
|
# changed-line granularity. See the header comment.
|
|
- name: Prettier (modified files, report-only)
|
|
continue-on-error: true
|
|
run: |
|
|
set -euo pipefail
|
|
if [ ! -s modified.txt ]; then echo "no modified files"; exit 0; fi
|
|
xargs -a modified.txt pnpm exec prettier --list-different > unformatted.txt || true
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
echo "::warning file=$f::Not formatted (pre-existing, not blocking). Run: pnpm exec prettier --write $f"
|
|
done < unformatted.txt
|
|
if [ -s unformatted.txt ]; then
|
|
echo "$(wc -l < unformatted.txt) modified file(s) are not formatted."
|
|
exit 1
|
|
fi
|
|
echo "all modified files formatted"
|
|
|
|
typecheck:
|
|
name: Typecheck (main pushes / fork PRs / non-main base)
|
|
runs-on: ubuntu-latest
|
|
# 🔴 NARROWED, not retired (talos-infra #855, stage 1). The in-cluster Tekton
|
|
# `pr-check` pipeline runs `tsc --noEmit` on every PR and posts the result as
|
|
# the `tekton / typecheck` commit status. Running this job as well typechecks
|
|
# the same tree a second time on every internal PR. Tekton is the one that
|
|
# kept reporting through the 2026-08-06 Actions `major_outage` — it POLLS the
|
|
# PR API on a 1m interval rather than taking a webhook, and the incident
|
|
# degraded webhook delivery.
|
|
#
|
|
# This `if:` is the complement of what Tekton covers. Both disjuncts were
|
|
# measured on 2026-08-07, not assumed:
|
|
#
|
|
# fork PR — the Tekton side gates fork-authored PRs away by author
|
|
# association, deliberately: it executes PR code on our own
|
|
# infrastructure, and GitHub's untrusted-code sandbox is doing work we
|
|
# would otherwise have to build. So this job is the only typecheck a fork
|
|
# PR can get. ⚠️ In practice this arm is DORMANT — see the header.
|
|
#
|
|
# base != main — the Tekton side only watches PRs targeting `main`, so a
|
|
# PR targeting `release` gets no check from it at all. Rare but real: 4
|
|
# PRs have ever targeted `release` (0 of the last 200 merged) — most
|
|
# recent #2582, closed 2026-06-16; last merged #2248, 2026-05-09. Rare is
|
|
# what makes this cheap, not a reason to drop it.
|
|
#
|
|
# push to main — the THIRD disjunct, added 2026-08-21. Read this before
|
|
# trimming it as redundant with the first: the Tekton `pr-check`
|
|
# pipeline is driven by a resource that watches OPEN PULL REQUESTS
|
|
# targeting `main`, so a COMMIT on `main` is outside its trigger
|
|
# entirely. The only Tekton trigger on the main branch builds a
|
|
# container image for the staging deployment; it runs no `tsc` and posts
|
|
# no status. So on this path there is no second typecheck to be a
|
|
# duplicate of — this job is the only one, exactly as in the fork case.
|
|
#
|
|
# Deleting any disjunct silently removes the only typecheck on that path.
|
|
# The honest way to retire this job entirely is to widen Tekton's coverage
|
|
# first and prove it on a real PR of each shape.
|
|
#
|
|
# ⚠️ `github.event.pull_request` is null on a push event, so BOTH PR
|
|
# disjuncts evaluate to `null != <string>` — which is TRUE. The push arm is
|
|
# therefore already implied and the explicit `github.event_name == 'push'`
|
|
# below is redundant *today*. It is written anyway because the redundancy is
|
|
# an accident of null-comparison semantics rather than a decision, and the
|
|
# next person to tighten these disjuncts (e.g. by pinning
|
|
# `github.event_name == 'pull_request' && ...`) would silently drop main
|
|
# coverage with no test naming it. The guard test asserts the push arm.
|
|
#
|
|
# The name changed with the gate so the checks list explains its own absence.
|
|
# Safe: neither `main` nor `release` has any required_status_checks (verified
|
|
# 2026-08-07 via the branch-protection API, re-verified 2026-08-21), so no
|
|
# merge gate keys on the old name.
|
|
if: >-
|
|
github.event_name == 'push'
|
|
|| github.event.pull_request.head.repo.full_name != github.repository
|
|
|| github.event.pull_request.base.ref != 'main'
|
|
# event-engine-common is public and fetched over HTTPS, so this needs no
|
|
# secret — which is why the ssh-agent step is gone rather than merely unused.
|
|
# Leaving it in would have kept the fork case broken AND masked the HTTPS
|
|
# path: webfactory/ssh-agent rewrites
|
|
# url."git@key-<sha>.github.com:<owner>/<repo>".insteadOf "https://github.com/<owner>/<repo>"
|
|
# for any loaded key whose comment names a github.com repo, silently sending
|
|
# the new HTTPS URL back over SSH.
|
|
timeout-minutes: 25
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
submodules: true
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Must stay `pnpm run typecheck`, not a bare `tsc --noEmit`. The script is
|
|
# a wrapper (scripts/typecheck.mjs) that sets the V8 heap cap AND
|
|
# classifies the outcome: a `tsc` that dies of heap exhaustion emits zero
|
|
# diagnostics, so a raw invocation produces an empty log that reads as a
|
|
# clean pass to anyone scanning it. The wrapper says CRASHED instead.
|
|
- name: Typecheck
|
|
run: pnpm run typecheck
|
|
|
|
unit:
|
|
name: Unit tests
|
|
runs-on: ubuntu-latest
|
|
# ~8,000 tests that until now only ran on whoever remembered to run them.
|
|
# Node env, no browser — component tests (`*.browser.test.tsx`) are not here
|
|
# on purpose: they need Chromium, which this job does not install. That is
|
|
# the whole reason. This comment used to add "and carry the cold-optimizeDeps
|
|
# flake documented at vitest.config.mts:98-124", which was wrong: those lines
|
|
# document the FIX for that flake, not an open one. vitest.config.mts:109-127
|
|
# dedupes React and pre-bundles `vitest-browser-react` + the JSX runtimes so
|
|
# the optimize pass happens BEFORE the run instead of reloading mid-run, and
|
|
# calls itself the canonical fix; 98-102 is a separate hookTimeout fix.
|
|
# Don't cite a cold-cache flake as a reason to keep this job Chromium-free.
|
|
#
|
|
# REPORT-ONLY to start, deliberately. A full local run passed 8,937/8,943 but
|
|
# timed out 5 tests under machine load; every one of them passed in isolation
|
|
# (that file runs in 138ms — the cost is the ~33s import/transform phase, not
|
|
# the assertions). The per-test timeout is 60s, so a 2-core runner could trip
|
|
# the same way. Blocking on that from day one would red unrelated PRs and the
|
|
# job would be switched off within a week — the same reasoning the ESLint and
|
|
# Prettier modified-file steps are built on.
|
|
#
|
|
# FLIP TO BLOCKING once a couple of weeks of runs show a clean pass rate.
|
|
# Remove `continue-on-error` and this comment together.
|
|
#
|
|
# WHERE THAT STANDS, measured 2026-08-20 so the next reader does not repeat the
|
|
# archaeology. Over the 39 most recent completed runs the `Unit tests` STEP was red
|
|
# in 8, across 8 DISTINCT branches — which reads as flake but was not: every one
|
|
# predates #4191, which fixed a ledger test that had been TRUNK red and therefore
|
|
# failed on every PR regardless of its contents. Of the runs started after that
|
|
# merge, 5 of 5 are green across 4 branches. Encouraging, and far too short a
|
|
# window to act on — 36 minutes is not "a couple of weeks".
|
|
#
|
|
# Two things to know before re-measuring. On a PULL REQUEST the run-level
|
|
# `conclusion` is USELESS here: `continue-on-error` makes it `success` while the
|
|
# step underneath is `failure`, so read `steps[].conclusion` from the jobs
|
|
# endpoint, not the run. That is not true of a push run — see below.
|
|
#
|
|
# The second used to read "this workflow is `pull_request`-only, so the suite
|
|
# never runs against `main` itself — a trunk-red test shows up as every PR going
|
|
# red at once". That was accurate when written and is now the thing this change
|
|
# fixes; the signature it describes is still worth recognising in runs from
|
|
# before 2026-08-21.
|
|
#
|
|
# ── REPORT-ONLY ON PULL REQUESTS, REAL VERDICT ON `main` ────────────────────
|
|
#
|
|
# The soak above is a claim about PRs and it is left exactly as it is: flipping
|
|
# the PR side to blocking is a separate, deliberately-unmade decision with its own
|
|
# open ticket, and nothing here should be read as making it.
|
|
#
|
|
# `main` is a different question, because the reason for report-only does not
|
|
# exist there. Report-only buys "do not red an unrelated PR over a load-timeout";
|
|
# on a push the merge has already happened, so there is no unrelated work to red
|
|
# and nothing to block. What `continue-on-error: true` WOULD buy on `main` is the
|
|
# exact failure this repo just spent two months inside: a run whose conclusion is
|
|
# `success` while the suite underneath is broken. A green that has to be
|
|
# disbelieved is worse than no run at all — checking "is main green?" would go on
|
|
# returning success, and the staleness would merely have been replaced by a lie.
|
|
#
|
|
# So: the flake risk is accepted on `main` and the verdict is rendered honestly.
|
|
# If the suite proves too noisy there, the fix is to de-flake the tests or to
|
|
# narrow what runs on push — NOT to hide the result behind continue-on-error.
|
|
continue-on-error: ${{ github.event_name == 'pull_request' }}
|
|
# ── SHARDED ACROSS 4 RUNNERS ────────────────────────────────────────────
|
|
#
|
|
# Measured before the change, n=12 runs of this job, from the Actions API:
|
|
#
|
|
# Unit tests step 555.5s <- variable, scales with 1/shards
|
|
# Install 30.0s |
|
|
# checkout 21.0s | fixed overhead, paid by EVERY shard
|
|
# setup-node 13.0s |
|
|
# set up job + pnpm 2.5s | = 66.5s
|
|
# -------------------------
|
|
# total 622.0s = the 10.4m job median
|
|
#
|
|
# So T(N) = 66.5 + 555.5/N: 1 -> 10.4m, 2 -> 5.7m, 3 -> 4.2m, 4 -> 3.4m,
|
|
# 6 -> 2.7m, 8 -> 2.3m. Runner-minutes go 10.4 -> 13.7 at N=4 (+32%).
|
|
#
|
|
# 🔴 WHY 4 AND NOT MORE. `App unit tests + typecheck` is 2.9m. At N=4 this
|
|
# job lands at ~3.4m and at N=5 it reaches parity, so past N=5 sharding
|
|
# optimises something that is NO LONGER THE BOTTLENECK — the PR's critical
|
|
# path floors at ~2.9m however far you go. Raising this number without
|
|
# first making the other jobs faster buys wall-clock nothing and costs
|
|
# runner-minutes linearly.
|
|
#
|
|
# BALANCE: ASSUMED BEFOREHAND, NOW MEASURED. `--shard` is not cost-aware and
|
|
# this suite's cost is bimodal (p50 66 modules per file, p75 1088 — see
|
|
# claudedocs/test-perf-measurement-envelope-2026-08-15.md and
|
|
# scripts/test-perf/README.md), so uneven shards were the live risk. Measured
|
|
# on the first green sharded run, 2026-08-25:
|
|
#
|
|
# shard 1 2 3 4
|
|
# files 355 354 354 354 <- vitest splits by FILE COUNT
|
|
# tests 5794 5142 5774 5447
|
|
# wall 3.3m 3.6m 3.8m 3.4m <- 1.14x spread
|
|
#
|
|
# The file split is near-exact; the 14% spread is the cost distribution
|
|
# showing through it. Adequate at N=4 — but the margin narrows as N rises and
|
|
# per-shard file counts fall, so READ THE FOUR DURATIONS after any change to
|
|
# this matrix. If one dominates, assign files by measured per-file cost
|
|
# (scripts/test-perf/reporter.mjs emits it) rather than raising N.
|
|
#
|
|
# ⚠️ Suite size moves fast: 1,065 files / 16,784 tests on 2026-08-15 became
|
|
# 1,417 files / 22,157 executed ten days later, +33%. Any figure here is a
|
|
# snapshot — re-derive before quoting one.
|
|
#
|
|
# `fail-fast: false` is load-bearing: the default cancels the sibling shards
|
|
# the moment one goes red, which throws away three quarters of the signal
|
|
# from a job whose entire purpose is to report.
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
# Per-shard, not the whole suite: 555.5/4 + overhead is ~3.4m, so 15m is
|
|
# ~4x headroom. It is NOT 30/4 — the fixed 66.5s does not shard.
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
submodules: true
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# ── VITE PRE-BUNDLE CACHE ───────────────────────────────────────────
|
|
#
|
|
# A DIFFERENT cache from the one `setup-node` keeps. `cache: pnpm` above
|
|
# caches the pnpm STORE — the downloaded packages. This caches what Vite's
|
|
# dependency optimizer BUILDS out of them.
|
|
#
|
|
# What is in there, measured 2026-08-25 on a warm local checkout rather
|
|
# than assumed: `node_modules/.vite` is 302 MB, all of it under
|
|
# `.vite/vitest/<project-config-hash>/{deps,deps_ssr}` — esbuild pre-bundles
|
|
# — plus a few hundred KB of `results.json`. It is NOT per-test-file output,
|
|
# which is why the key below does not mention the shard's file list: the
|
|
# content is a function of the dependency set and the Vitest config, so
|
|
# `pnpm-lock.yaml` is the right thing to key on.
|
|
#
|
|
# Every one of the 4 shards rebuilds this from cold on every PR today, and
|
|
# nothing in the repo caches it. (Do not reach for the `optimizeDeps`
|
|
# pre-bundle list in vitest.config.mts as prior art here: that block belongs
|
|
# to the `component` project, which this job does not run. It moves WHEN the
|
|
# optimize pass happens within a run; it does not carry anything between
|
|
# runs.)
|
|
#
|
|
# 🔴 RESTORE ALWAYS, SAVE ONLY ON `main`, and that asymmetry is the whole
|
|
# design rather than a tuning choice. An Actions cache saved by a PR run is
|
|
# scoped to `refs/pull/N/merge`, readable by NOTHING else, and counts
|
|
# against the same 10 GB repo budget as everything useful. Saving from PRs
|
|
# would mint 4 dead entries per open PR — the exact leak this PR's sibling
|
|
# change exists to clean up (20 orphans, 9.65 GB, 95.3% of the budget).
|
|
# Saving only on `main` keeps this at FOUR entries total, restorable by
|
|
# every PR because a PR can read its base branch's scope.
|
|
#
|
|
# Budget: 4 x 302 MB = 1.21 GB worst case, and that is an over-estimate
|
|
# twice over — the 302 MB is the full local project set (this job runs
|
|
# `--project unit*`, a subset) and Actions compresses on upload.
|
|
#
|
|
# A PR that changes `pnpm-lock.yaml` misses the exact key and falls back to
|
|
# the prefix, restoring the previous lockfile's bundles; Vite validates its
|
|
# own optimizer metadata and re-optimizes what actually moved. It then
|
|
# saves nothing, so that PR pays cold-ish cost until it merges. That is the
|
|
# correct trade: lockfile changes are rare and per-PR entries are the bug.
|
|
- name: Restore Vite pre-bundle cache
|
|
id: vite-cache
|
|
uses: actions/cache/restore@v4
|
|
with:
|
|
path: node_modules/.vite
|
|
key: vite-unit-${{ runner.os }}-${{ runner.arch }}-${{ matrix.shard }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
vite-unit-${{ runner.os }}-${{ runner.arch }}-${{ matrix.shard }}-
|
|
|
|
# `scripts/test-unit-run.mjs` forwards process.argv.slice(2) straight into
|
|
# `vitest run --project unit* ...args`, and its queueDecision() returns
|
|
# early on CI, so the dev-server queue never sees this.
|
|
#
|
|
# 🔴 NO `--` SEPARATOR, and that is not a style choice. pnpm forwards `--`
|
|
# through LITERALLY rather than consuming it (measured on pnpm 10.28.1,
|
|
# the version `packageManager` pins and `pnpm/action-setup` installs; also
|
|
# reproduced on 9.0.0):
|
|
#
|
|
# pnpm run x -- --shard=1/4 -> ["--", "--shard=1/4"]
|
|
# pnpm run x --shard=1/4 -> ["--shard=1/4"]
|
|
#
|
|
# vitest then DISCARDS every argument after the bare `--` — it does not
|
|
# treat them as filename filters, which an earlier revision of this comment
|
|
# claimed. Measured both ways:
|
|
#
|
|
# vitest list --filesOnly --project 'unit*' -- assert-shard-ran -> 1416 files
|
|
# vitest list --filesOnly --project 'unit*' assert-shard-ran -> 1 file
|
|
#
|
|
# The distinction matters when you are debugging: as FILTERS the shard
|
|
# would run almost nothing, but what actually happens is the INVERSE — no
|
|
# `--shard`, no `--outputFile`, so every runner executes the WHOLE suite and
|
|
# writes no report, while the step still exits 0. On the run that shipped
|
|
# this bug the four `Unit tests` steps took 7m30s-9m54s each, which is the
|
|
# tell. The assert step below is what caught it, on its first live run.
|
|
#
|
|
# The sibling `packages` / `apps` jobs have always used this separator-less
|
|
# form; copy them, not the shell habit.
|
|
- name: Unit tests
|
|
run: pnpm run test:unit:run --shard=${{ matrix.shard }}/4 --reporter=default --reporter=json --outputFile=unit-report.json
|
|
|
|
# 🔴 POSITIVE CONTROL. A `--shard` expression that matches no files exits 0
|
|
# and reports a green check — the same silent-zero class this repo has been
|
|
# bitten by before (a suite that fails to IMPORT reports "no tests", not a
|
|
# failure). A per-shard test count is the cheapest thing that can tell
|
|
# "this shard passed" apart from "this shard ran nothing", and it must run
|
|
# even when the suite above went red, or a red shard hides a zero shard.
|
|
- name: Assert this shard actually ran tests
|
|
# `!cancelled()` rather than `always()`: the guard must still run after a RED
|
|
# suite (that is the case it exists for — a red shard must not hide a zero
|
|
# shard), but a CANCELLED run has no report to read and would render a
|
|
# confusing red assert step on top of the cancellation. Observed on shard 3
|
|
# of the run that shipped the `--` bug.
|
|
if: ${{ !cancelled() }}
|
|
run: node scripts/ci/assert-shard-ran.mjs unit-report.json ${{ matrix.shard }} 4
|
|
|
|
# See the restore step for why this is `main`-only. Three further details:
|
|
#
|
|
# `!cancelled()` rather than the default — a RED suite still produced a
|
|
# valid set of pre-bundles, and refusing to save them would mean a
|
|
# single red commit on `main` leaves every subsequent PR cold.
|
|
# `cache-hit != 'true'` — an exact-key hit means this content is already
|
|
# stored under this key, so re-uploading ~300 MB would buy nothing. A
|
|
# PREFIX hit leaves this false, which is right: that content belongs to
|
|
# a different lockfile and does want re-saving under the new key.
|
|
# `continue-on-error` — a cache save is best-effort infrastructure. Two
|
|
# shards can race the same key, and the loser reports "another job may
|
|
# be creating this cache". That must not red a commit on `main`, where
|
|
# this job is no longer report-only.
|
|
- name: Save Vite pre-bundle cache
|
|
if: ${{ !cancelled() && github.event_name == 'push' && steps.vite-cache.outputs.cache-hit != 'true' }}
|
|
continue-on-error: true
|
|
uses: actions/cache/save@v4
|
|
with:
|
|
path: node_modules/.vite
|
|
key: vite-unit-${{ runner.os }}-${{ runner.arch }}-${{ matrix.shard }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
|
|
packages:
|
|
name: Package unit tests
|
|
runs-on: ubuntu-latest
|
|
# The nine `packages/*` suites — 616 tests that, until this job existed, NO CI job ran.
|
|
# The `unit` job above runs `vitest run --project unit`, whose `include` is root-relative
|
|
# (`src/**`, `scripts/**`), so nothing in this repo ever invoked a workspace package's
|
|
# suite. That is not a small gap: the schema-drift detector shipped 81 tests into it,
|
|
# and two of them had been RED on `main` since #3592 corrected the schema out from under
|
|
# their assertions, with nothing to say so.
|
|
#
|
|
# NOT `continue-on-error`, unlike `unit`. Be precise about what that does and does not
|
|
# buy: `main` has branch protection but NO required_status_checks, so a red check here
|
|
# does not prevent a merge. The difference from `unit` is that this renders RED rather
|
|
# than red-but-ignored — a signal someone has to look at and dismiss, not one the UI
|
|
# hides. Making it an actual interlock means adding "Package unit tests" to the required
|
|
# checks, which is a repo-settings change, not a workflow one.
|
|
#
|
|
# The reasoning that made `unit` report-only still does not transfer, which is why this
|
|
# one is not marked ignorable: `unit` is ~12,000 tests whose cost is a ~33s
|
|
# import/transform phase per file, and it timed out five tests under load. This is 616
|
|
# tests with no browser, no database and no Next module graph — ~15s locally.
|
|
#
|
|
# DB-backed tiers self-skip: `@civitai/db-queries` sources DATABASE_URL from the root
|
|
# `.env`, which does not exist on a runner, so its 8 DB-backed tests across three files
|
|
# (6 in tag.db.explain, 1 in model.db.explain, 1 in enum-array-parsers.explain) report
|
|
# as skipped. They are visible as skips in the summary rather than as a silent absence.
|
|
# Those EXPLAIN checks therefore have NEVER run in CI — they are laptop-only evidence.
|
|
# The same is true of the 16-case Kysely/Prisma parity suite, which lives in the app's
|
|
# `unit` project and self-skips without KYSELY_PARITY_DATABASE_URL.
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
submodules: true
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Not a package suite, and deliberately parked in this job anyway: it needs a completed
|
|
# `pnpm install` and a job that FAILS the build, and this is the cheapest one that is
|
|
# both. Adding a fourth job would buy a tidier name for the price of a fourth full
|
|
# install; the step name is what GitHub shows on a failure, so blame stays readable.
|
|
#
|
|
# What it guards: `/api/og` returned 500 on every request for two days because Next
|
|
# 16.3.0 leaves the libvips SVG loader blocked and `next/og` rasterizes through it. We
|
|
# carry the upstream one-line fix as a pnpm patch. The behavioural regression test for
|
|
# that outage lives in the `unit` job above — which is `continue-on-error`, so it cannot
|
|
# stop the same breakage reaching production a second time. This runs the structural
|
|
# half of it, against the INSTALLED node_modules rather than the patch file, so a patch
|
|
# that exists but did not apply is caught. ~50ms, no test runner.
|
|
- name: Next SVG loader patch applied
|
|
run: node scripts/ci/assert-next-svg-patch-applied.mjs
|
|
|
|
# Parked here for the same two reasons as the step above: it needs a completed
|
|
# `pnpm install` and a job that FAILS the build, and this is the cheapest one that is
|
|
# both. `postinstall` has already run `db:generate`, so the re-run inside the script is
|
|
# idempotent and costs ~1s.
|
|
#
|
|
# What it guards: `packages/civitai-db-schema/src/*` is GENERATED but TRACKED, so a
|
|
# commit whose author's editor reformatted it lands a file that disagrees with its own
|
|
# generator, and every developer then gets a spurious dirty tree after `pnpm install`.
|
|
# `db:check-generated` has existed for exactly this and was wired to NOTHING — measured
|
|
# 2026-08-21, it appeared in no workflow. `enums.ts` flipped between the wrapped and
|
|
# unwrapped forms FOUR times in four days (470f0fd993 0, 4214ecb10b 30, 15c1408d3d 31,
|
|
# a5ed2dc83e 0, d0327f55ef 31), and TWICE AFTER `15c1408d3d` made the generator emit
|
|
# Prettier-formatted bytes specifically to end it. That fix is correct; it was a
|
|
# convention with nothing enforcing it, which is why it did not hold.
|
|
#
|
|
# 🔴 The `test -f` is not decoration. `git diff --exit-code -- <path>` exits 0 when the
|
|
# path exists on NEITHER side, so if these files are ever moved, renamed or untracked
|
|
# this gate goes silently and permanently GREEN instead of failing. Verified: an
|
|
# `--exit-code` diff against a nonexistent sibling path exits 0 here today.
|
|
#
|
|
# 🔴 Note for anyone re-testing this: a working-tree edit CANNOT exercise it. The script
|
|
# regenerates before it diffs, so it overwrites your mutation and passes. The regression
|
|
# being caught is a COMMITTED file that disagrees with the generator, so the mutant has
|
|
# to be committed too. Measured both ways: uncommitted edit -> exit 0 (proves nothing),
|
|
# same edit committed -> exit 1 with 62 changed alias lines (= 2 x 31 aliases).
|
|
- name: Generated DB schema files match their generator
|
|
run: |
|
|
test -f packages/civitai-db-schema/src/enums.ts
|
|
pnpm run db:check-generated
|
|
|
|
- name: Package unit tests
|
|
run: pnpm run test:packages:run --reporter=default --reporter=json --outputFile=packages-report.json
|
|
|
|
# A green vitest run is a claim, not evidence: `--project` matching nothing exits 0,
|
|
# and so does a config whose globs stopped resolving. This asserts the LEDGER — every
|
|
# workspace package with a vitest config and a test file on disk must appear in the
|
|
# results — so the job fails when the executed set shrinks, which is the regression the
|
|
# totals cannot see.
|
|
- name: Assert every package suite actually ran
|
|
if: always()
|
|
run: node scripts/ci/assert-workspace-suites-ran.mjs packages-report.json packages
|
|
|
|
apps:
|
|
# Named for BOTH things it runs. It used to be "App unit tests", and the typecheck steps
|
|
# were added under that name — so a type error in an app would have rendered in the checks
|
|
# list as a failing unit-test job, pointing the reader at the wrong suite. Safe to rename:
|
|
# neither `main` nor `release` has any required_status_checks (re-verified 2026-08-20 via
|
|
# the branch-protection API, not inherited from the earlier note), so no merge gate keys
|
|
# on the old string.
|
|
name: App unit tests + typecheck
|
|
runs-on: ubuntu-latest
|
|
# The `apps/*` suites — the same gap as `packages` above, in the sibling directory, and
|
|
# missed when that one was closed: the root config registered `packages/*/vitest.config.*`
|
|
# as projects and nothing for `apps/*`, so 369 tests across five apps had never once run
|
|
# in CI. They only ever ran for whoever remembered `pnpm --filter <app> test` by hand.
|
|
#
|
|
# A SEPARATE job rather than folding the apps into `packages`, because the two selectors
|
|
# have to stay disjoint anyway (see the `apps/*` note in vitest.config.mts) and separate
|
|
# jobs keep the blame attributable: a red app suite should not make the package suites
|
|
# look broken. Costs one more `pnpm install`; these suites run in seconds.
|
|
#
|
|
# Not `continue-on-error`, for the same reason as `packages`: no browser, no database, no
|
|
# Next module graph, ~7s locally. As there, RED here does not block a merge — `main` has
|
|
# no required_status_checks — it is a signal someone has to look at and dismiss.
|
|
#
|
|
# Selection is by vitest config, so an app without one is silently not covered here. Every app
|
|
# under `apps/` has one today; scripts/ci/assert-workspace-suites-ran.mjs is what notices if that
|
|
# stops being true.
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
submodules: true
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: App unit tests
|
|
run: pnpm run test:apps:run --reporter=default --reporter=json --outputFile=apps-report.json
|
|
|
|
# Same positive control as the packages job, and it earns its keep harder here: these
|
|
# projects are named by hand (`app:auth`, ...), so an app that loses its `test.name`
|
|
# stops being selected while the other four keep the run green and exit 0. Measured,
|
|
# not assumed — see the script header.
|
|
- name: Assert every app suite actually ran
|
|
if: always()
|
|
run: node scripts/ci/assert-workspace-suites-ran.mjs apps-report.json apps
|
|
|
|
# ── App typechecks ──────────────────────────────────────────────────
|
|
# No app under `apps/` was typechecked by anything before this. The root
|
|
# `pnpm run typecheck` is bounded by the root tsconfig `include` — `src`,
|
|
# `packages/*/src`, `scripts/local-dev/*.ts`, `tests`, `test`,
|
|
# `.next/types/**/*.ts` — with no `apps/*` entry, and BOTH tiers (this
|
|
# workflow's `typecheck` job and the `tekton / typecheck` status) run that
|
|
# same script, so the gap could not close on its own.
|
|
#
|
|
# Parked in this job rather than its own: `pnpm install` is the expensive
|
|
# part and this job has already paid for it. The tradeoff is that a type
|
|
# error renders under this job's name — hence the job rename above.
|
|
#
|
|
# Deliberately ONE step driving a script, not one `pnpm --filter` step per
|
|
# app. Per-app steps abort at the first red app, so a shared type change
|
|
# that breaks several of them reports one; and `pnpm --filter <name>` EXITS
|
|
# 0 WHEN THE NAME MATCHES NOTHING (measured, pnpm 10.28.1 — it prints "No
|
|
# projects matched the filters" and succeeds), so a hardcoded list of
|
|
# package names is one chance per app for a rename to turn this gate into a
|
|
# green no-op. The script derives the app set from disk, proves each filter
|
|
# selected a real package, and collects every failure. See its header.
|
|
- name: Typecheck apps
|
|
run: node scripts/ci/typecheck-apps.mjs
|
|
|
|
geometry:
|
|
name: Geometry tests
|
|
runs-on: ubuntu-latest
|
|
# The browser-mode `geometry` project — real Chromium, the real app cascade,
|
|
# an explicit phone viewport. See `test/geometry-setup.tsx` for what it loads
|
|
# and why it is a separate project from `component`.
|
|
#
|
|
# 🔴 THIS IS THE FIRST JOB IN THIS WORKFLOW THAT INSTALLS A BROWSER, AND IT IS
|
|
# ALIGNED WITH THE `unit` JOB'S COMMENT RATHER THAN AN OVERRIDE OF IT.
|
|
# That comment gives exactly one reason for keeping browser tests out:
|
|
#
|
|
# "Node env, no browser — component tests (`*.browser.test.tsx`) are not
|
|
# here on purpose: they need Chromium, which this job does not install.
|
|
# That is the whole reason."
|
|
#
|
|
# It is a statement about what THAT job provides, not a ban on providing it.
|
|
# The same comment then retracts the only other objection on the record —
|
|
# "Don't cite a cold-cache flake as a reason to keep this job Chromium-free"
|
|
# — and points at vitest.config.mts's dedupe + optimizeDeps pre-bundling as
|
|
# the canonical fix for it. So a job that DOES install Chromium satisfies the
|
|
# stated condition instead of overriding a decision.
|
|
#
|
|
# 🔴 GEOMETRY ONLY; `component` IS STILL UNGATED, AND THAT IS NOW VISIBLE
|
|
# RATHER THAN FIXED. No project selector in this workflow matches it
|
|
# (`unit*`, `@civitai/*`, `app:*`), and its only CI home is the preview
|
|
# pipeline's report-only `preview / component-tests`. Adding it here is a
|
|
# much larger change than this harness and belongs in its own PR, on measured
|
|
# cost: locally, on a 16-core box, `geometry` is 2 files / 10 tests in 9.07s
|
|
# while `component` is 212 files / 2,362 tests in 112.92s wall — and 334s of
|
|
# that is test time spread across workers, so a 2-core runner (browser pool
|
|
# `min(12, cpus - 1)` = 1 instance) does not divide it. That is a job an order
|
|
# of magnitude more expensive, with 212 files of pass/fail history nobody has
|
|
# ever seen in this workflow. Do it deliberately, not as a rider.
|
|
#
|
|
# REPORT-ONLY to start, mirroring `unit` and for the same stated reason:
|
|
# blocking on a brand-new tier from day one would red unrelated PRs and the
|
|
# job would be switched off within a week. Be precise about what
|
|
# `continue-on-error` does and does not change here — `main` has NO
|
|
# required_status_checks, so nothing in this workflow blocks a merge either
|
|
# way. What it buys is that a red renders as red-but-ignored rather than as a
|
|
# failing check on unrelated work while the tier settles.
|
|
#
|
|
# FLIP TO BLOCKING once a couple of weeks of runs show a clean pass rate, and
|
|
# remove `continue-on-error` and this paragraph together. What would make that
|
|
# safe, concretely: no run red for a reason other than a real geometry
|
|
# regression — in particular no Chromium launch failures and no timeouts on a
|
|
# 2-core runner. The tier is 10 tests and ~9s locally, so unlike `unit` there
|
|
# is no import/transform phase to blame; if it goes red it is either the code
|
|
# or the browser, and both are worth someone's attention.
|
|
continue-on-error: true
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
submodules: true
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version-file: .nvmrc
|
|
cache: pnpm
|
|
|
|
- name: Install
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# 🔴 THE WORKSPACE-LOCAL PLAYWRIGHT, NOT A PINNED VERSION WRITTEN HERE.
|
|
# `pnpm exec` resolves this repo's own `playwright` (^1.57.0), which knows
|
|
# the exact Chromium revision it wants and fetches THAT. A version named in
|
|
# this file would be a second pin to keep in step, and desynchronising the
|
|
# two is the documented failure: CLAUDE.md records a bump attempt where 59
|
|
# preview specs died with "Executable doesn't exist at
|
|
# .../chromium_headless_shell-<rev>/..." and not one spec ran.
|
|
#
|
|
# `--with-deps` installs the shared libraries Chromium needs on a bare
|
|
# ubuntu-latest image; `chromium` alone, because the browser config in
|
|
# vitest.config.mts declares `instances: [{ browser: 'chromium' }]` and
|
|
# firefox/webkit would be several hundred MB of download for nothing.
|
|
#
|
|
# NOTE FOR ANYONE DEBUGGING THIS FROM A NIXOS LAPTOP: a local
|
|
# revision mismatch (a nixpkgs `playwright-driver.browsers` bundle carrying
|
|
# chromium-1228 against this repo's 1200) is a property of THAT HOST, not of
|
|
# this repo. The escape hatch is `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`,
|
|
# honoured by vitest.config.mts. Do NOT move the repo's playwright pin to
|
|
# match a laptop — see CLAUDE.md, "Browser/component tests on NixOS".
|
|
- name: Install Chromium for Playwright
|
|
run: pnpm exec playwright install --with-deps chromium
|
|
|
|
- name: Geometry tests
|
|
run: pnpm exec vitest run --project geometry --reporter=default --reporter=json --outputFile=geometry-report.json
|
|
|
|
# 🔴 A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the same hazard the
|
|
# `packages` job's ledger exists for, one project over. `--project` matching
|
|
# NOTHING exits 0, and so does a config whose globs stopped resolving: this
|
|
# tier's glob (`src/**/*.geometry.test.tsx`) is deliberately disjoint from
|
|
# every other project's, which is exactly the kind of narrow pattern that
|
|
# can quietly stop matching. A job that silently runs zero tests is worse
|
|
# than no job, because it reports success.
|
|
#
|
|
# Both numbers are FLOORS, not equalities, so adding a geometry test never
|
|
# breaks this — only losing one does.
|
|
#
|
|
# 🔴 THE FILE COUNT COMES FROM `testResults.length`, NOT FROM
|
|
# `numTotalTestSuites`. Measured against a real report: this run is 2 files
|
|
# and `numTotalTestSuites` is 4, because that field counts `describe` blocks
|
|
# rather than files. A floor written against it would have been off by a
|
|
# factor that changes whenever someone adds a `describe`.
|
|
#
|
|
# `if: always()` so this runs when the tests FAIL too — an aborted runner
|
|
# writes no report at all, which is precisely the case this must catch, and
|
|
# it is indistinguishable from a healthy run if you only read the step's
|
|
# own status.
|
|
#
|
|
# All three arms were exercised before this was committed: a real report
|
|
# exits 0; a report with `testResults: []` / `numTotalTests: 0` exits 1; a
|
|
# missing report exits 1 with its own message.
|
|
- name: Assert the geometry tier actually collected something
|
|
if: always()
|
|
run: |
|
|
node - <<'JS'
|
|
// Minimum ledger for the `geometry` tier.
|
|
const MIN_FILES = 2;
|
|
const MIN_TESTS = 10;
|
|
const REPORT = 'geometry-report.json';
|
|
|
|
const fs = require('fs');
|
|
if (!fs.existsSync(REPORT)) {
|
|
console.error(
|
|
`geometry ledger: ${REPORT} was never written. The runner did not finish — a ` +
|
|
'browser that failed to launch, or a config error. That is NOT "the suite is ' +
|
|
'fine"; fail loudly.'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const r = JSON.parse(fs.readFileSync(REPORT, 'utf8'));
|
|
const files = Array.isArray(r.testResults) ? r.testResults.length : 0;
|
|
const tests = Number(r.numTotalTests ?? 0);
|
|
console.error(`geometry ledger: ${files} file(s), ${tests} test(s) collected`);
|
|
if (files < MIN_FILES || tests < MIN_TESTS) {
|
|
console.error(
|
|
`geometry ledger FAILED: expected at least ${MIN_FILES} file(s) and ${MIN_TESTS} ` +
|
|
`test(s), got ${files} and ${tests}. A vitest run whose --project matches ` +
|
|
'nothing exits 0, so a green verdict here would otherwise be a claim about the ' +
|
|
'selector, not about the code.'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
JS
|