refactor(ci): thin PR gate to lint/typecheck/build/gate; demote full suite to non-blocking push-to-main workflow
- pr-checks.yml: gate job = boot smoke + pnpm test:gate; shards/slow/inventory removed - full-suite.yml (new): 4-way shards, engine slow tier, inventory guard on push to main - ci.yml deleted (trigger-disabled since FN-1541; was dead config) - ci-workflow.test.ts rewritten to pin the new gate shape; release/signing blocks preserved - docs/contributing.md: pnpm test:gate is the merge gate; verify:workspace repositioned as deep opt-in BRANCH-PROTECTION CUTOVER: set required checks to exactly [Lint, Typecheck, Build, Gate] after merging; open PRs must rebase.
This commit is contained in:
69
.github/workflows/ci.yml
vendored
69
.github/workflows/ci.yml
vendored
@@ -1,69 +0,0 @@
|
||||
name: CI
|
||||
|
||||
# CI auto-trigger disabled per FN-1541 — workflow preserved for manual use via workflow_dispatch
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02.
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node and install dependencies
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
test-shards:
|
||||
name: Test shard ${{ matrix.shard }}/3
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node and install dependencies
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Test (deterministic shard)
|
||||
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test-shards]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node and install dependencies
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build workspace
|
||||
run: pnpm build
|
||||
|
||||
- name: Run CLI slow lane (opt-in suites)
|
||||
run: pnpm test:slow-cli
|
||||
|
||||
- name: Build standalone binary
|
||||
run: pnpm --filter @runfusion/fusion build:exe
|
||||
|
||||
- name: Verify binary exists
|
||||
run: test -f packages/cli/dist/fn
|
||||
167
.github/workflows/full-suite.yml
vendored
Normal file
167
.github/workflows/full-suite.yml
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
name: Full Suite (non-blocking)
|
||||
|
||||
# The demoted test tier (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md).
|
||||
# Runs the full sharded suite, the engine slow tier, and the dashboard
|
||||
# inventory guard on every push to main — post-merge signal only. These jobs
|
||||
# are NON-BLOCKING by design: they never run on PRs and must never be added
|
||||
# to branch-protection required checks. A red run here is information, not a
|
||||
# merge stopper; see docs/testing.md for the quarantine ratchet that keeps
|
||||
# this tier honest.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: full-suite-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# FN-4863: Opt JavaScript actions into Node 24 ahead of GitHub's forced cutover on 2026-06-02.
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
test-shards:
|
||||
name: Test shard ${{ matrix.shard }}/4
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Engine tests run real git operations (merge-base against main,
|
||||
# case-variant ref checks) that require full history. Shallow
|
||||
# clones silently break tests like worktree-acquisition's resume
|
||||
# misbinding path.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
# Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/
|
||||
# for 8 packages (~71s) on every shard because CI starts with no dist.
|
||||
# Key on a stable, pre-build, git-based hash of ALL build packages' source
|
||||
# inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is
|
||||
# the exact failure mode this repo has been bitten by (FN-4232/FN-4605),
|
||||
# and ensureTestArtifacts still validates/rebuilds anything missing-or-stale
|
||||
# after restore, so a miss is safe but a wrong-content hit would not be.
|
||||
# NEVER add node_modules here (breaks Windows pnpm junctions elsewhere).
|
||||
- name: Compute dist source hash
|
||||
id: dist-hash
|
||||
run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache built dist artifacts
|
||||
id: dist-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/core/dist
|
||||
packages/dashboard/dist
|
||||
packages/engine/dist
|
||||
packages/plugin-sdk/dist
|
||||
plugins/fusion-plugin-dependency-graph/dist
|
||||
plugins/fusion-plugin-hermes-runtime/dist
|
||||
plugins/fusion-plugin-openclaw-runtime/dist
|
||||
plugins/fusion-plugin-paperclip-runtime/dist
|
||||
key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }}
|
||||
|
||||
# On a cache HIT, restored dist files carry their save-time mtimes while
|
||||
# checkout rewrites src mtimes to "now" (src newer than dist), which would
|
||||
# make ensureTestArtifacts' mtime fallback rebuild everything and defeat
|
||||
# the cache. Seed the per-package content-hash cache so its content-hash
|
||||
# short-circuit fires instead. ensureTestArtifacts still runs (inside
|
||||
# test:ci:shard) and rebuilds anything genuinely missing/changed.
|
||||
- name: Seed artifact hash-cache on cache hit
|
||||
if: steps.dist-cache.outputs.cache-hit == 'true'
|
||||
run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache
|
||||
|
||||
- name: Test (deterministic shard)
|
||||
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4
|
||||
|
||||
# Each shard emits per-file vitest JSON timing reporter output under
|
||||
# .timings/. Upload as an artifact so the timing snapshot can be
|
||||
# refreshed locally/from the default branch via
|
||||
# `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the
|
||||
# snapshot automatically — refresh is manual/scheduled only.
|
||||
- name: Upload per-shard test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-timings-shard-${{ matrix.shard }}
|
||||
# Relative outputFile paths mean each package writes its own
|
||||
# <pkgDir>/.timings/ file — glob the whole tree, not just the root.
|
||||
path: |
|
||||
.timings/timings-*.json
|
||||
packages/*/.timings/timings-*.json
|
||||
plugins/*/.timings/timings-*.json
|
||||
plugins/examples/*/.timings/timings-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# The dashboard quality gate used to enumerate its test files by hand, so any
|
||||
# unenumerated app/ or src/ test file ran in NO project. This guard fails when
|
||||
# a dashboard test file is neither executed by a quality project (curated +
|
||||
# backfill lanes) nor on the reviewed skip-list. Cheap: it only runs
|
||||
# `vitest list`, not the tests.
|
||||
test-inventory-guard:
|
||||
name: Dashboard curated-gate guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
# Same dist-artifact cache as test-shards (L1): the curated-gate guard runs
|
||||
# `vitest list`, whose config resolution can touch built dist, so it also
|
||||
# pays the cold-dist rebuild. Exact-match key on the pre-build source hash;
|
||||
# NO restore-keys (stale dist is the failure mode), NO node_modules.
|
||||
- name: Compute dist source hash
|
||||
id: dist-hash
|
||||
run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache built dist artifacts
|
||||
id: dist-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/core/dist
|
||||
packages/dashboard/dist
|
||||
packages/engine/dist
|
||||
packages/plugin-sdk/dist
|
||||
plugins/fusion-plugin-dependency-graph/dist
|
||||
plugins/fusion-plugin-hermes-runtime/dist
|
||||
plugins/fusion-plugin-openclaw-runtime/dist
|
||||
plugins/fusion-plugin-paperclip-runtime/dist
|
||||
key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }}
|
||||
|
||||
- name: Seed artifact hash-cache on cache hit
|
||||
if: steps.dist-cache.outputs.cache-hit == 'true'
|
||||
run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache
|
||||
|
||||
- name: Assert every dashboard test file is gated or skip-listed
|
||||
run: node scripts/check-test-inventory.mjs --dashboard-curated
|
||||
|
||||
# The engine-slow tier (src/**/*.slow.test.ts) runs here with a non-empty
|
||||
# execution assertion, so a glob/config drift that silently empties the tier
|
||||
# fails this workflow instead of passing vacuously. Engine slow tests do real
|
||||
# git operations, so a full clone (fetch-depth: 0) is required.
|
||||
test-slow:
|
||||
name: Engine slow tier
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Run engine-slow with non-empty-execution assertion
|
||||
run: node scripts/assert-engine-slow-nonempty.mjs
|
||||
150
.github/workflows/pr-checks.yml
vendored
150
.github/workflows/pr-checks.yml
vendored
@@ -1,12 +1,21 @@
|
||||
name: PR Checks
|
||||
|
||||
# The thin trusted merge gate (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md).
|
||||
# Blocking checks are exactly: Lint, Typecheck, Build, Gate.
|
||||
#
|
||||
# BRANCH-PROTECTION CUTOVER: required status checks are matched by job name.
|
||||
# When this file changes job names, update the repo's branch-protection
|
||||
# required checks to exactly [Lint, Typecheck, Build, Gate] — a stale required
|
||||
# name (e.g. "Test shard 1/4") that no longer reports will block every PR
|
||||
# with "Expected — waiting for status". Open PRs must rebase onto main after
|
||||
# the cutover so they run this workflow shape.
|
||||
#
|
||||
# Everything that used to run here as shards / slow tier / inventory guard is
|
||||
# non-blocking and lives in full-suite.yml (push to main).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# Also run on every push to main so post-merge regressions surface
|
||||
# immediately instead of being discovered on the next PR.
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
@@ -62,94 +71,13 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
test-shards:
|
||||
name: Test shard ${{ matrix.shard }}/4
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Engine tests run real git operations (merge-base against main,
|
||||
# case-variant ref checks) that require full history. Shallow
|
||||
# clones silently break tests like worktree-acquisition's resume
|
||||
# misbinding path.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
# Dist-artifact cache (L1): ensureTestArtifacts otherwise rebuilds dist/
|
||||
# for 8 packages (~71s) on every shard because CI starts with no dist.
|
||||
# Key on a stable, pre-build, git-based hash of ALL build packages' source
|
||||
# inputs. Exact-match only — NO restore-keys: a partial/stale dist hit is
|
||||
# the exact failure mode this repo has been bitten by (FN-4232/FN-4605),
|
||||
# and ensureTestArtifacts still validates/rebuilds anything missing-or-stale
|
||||
# after restore, so a miss is safe but a wrong-content hit would not be.
|
||||
# NEVER add node_modules here (breaks Windows pnpm junctions elsewhere).
|
||||
- name: Compute dist source hash
|
||||
id: dist-hash
|
||||
run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache built dist artifacts
|
||||
id: dist-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
packages/core/dist
|
||||
packages/dashboard/dist
|
||||
packages/engine/dist
|
||||
packages/plugin-sdk/dist
|
||||
plugins/fusion-plugin-dependency-graph/dist
|
||||
plugins/fusion-plugin-hermes-runtime/dist
|
||||
plugins/fusion-plugin-openclaw-runtime/dist
|
||||
plugins/fusion-plugin-paperclip-runtime/dist
|
||||
key: dist-${{ runner.os }}-${{ steps.dist-hash.outputs.hash }}
|
||||
|
||||
# On a cache HIT, restored dist files carry their save-time mtimes while
|
||||
# checkout rewrites src mtimes to "now" (src newer than dist), which would
|
||||
# make ensureTestArtifacts' mtime fallback rebuild everything and defeat
|
||||
# the cache. Seed the per-package content-hash cache so its content-hash
|
||||
# short-circuit fires instead. ensureTestArtifacts still runs (inside
|
||||
# test:ci:shard) and rebuilds anything genuinely missing/changed.
|
||||
- name: Seed artifact hash-cache on cache hit
|
||||
if: steps.dist-cache.outputs.cache-hit == 'true'
|
||||
run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache
|
||||
|
||||
- name: Test (deterministic shard)
|
||||
run: pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4
|
||||
|
||||
# U1 (R4): each shard emits per-file vitest JSON timing reporter output
|
||||
# under .timings/. Upload as an artifact so the timing snapshot can be
|
||||
# refreshed locally/from the default branch via
|
||||
# `node scripts/ci-test-shard.mjs --write-timings`. We do NOT commit the
|
||||
# snapshot from PR branches — refresh is manual/scheduled only.
|
||||
- name: Upload per-shard test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-timings-shard-${{ matrix.shard }}
|
||||
# Relative outputFile paths mean each package writes its own
|
||||
# <pkgDir>/.timings/ file — glob the whole tree, not just the root.
|
||||
path: |
|
||||
.timings/timings-*.json
|
||||
packages/*/.timings/timings-*.json
|
||||
plugins/*/.timings/timings-*.json
|
||||
plugins/examples/*/.timings/timings-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Plan U2 / R7: the dashboard quality gate used to enumerate its test files by
|
||||
# hand, so any unenumerated app/ or src/ test file ran in NO project. This
|
||||
# guard fails when a dashboard test file is neither executed by a quality
|
||||
# project (curated + backfill lanes) nor on the reviewed skip-list. Cheap:
|
||||
# it only runs `vitest list`, not the tests.
|
||||
test-inventory-guard:
|
||||
name: Dashboard curated-gate guard
|
||||
# The only merge-blocking TEST signal (R3). Runs the boot smoke (the app
|
||||
# starts and serves) plus the curated engine-core suite and the CI-shape
|
||||
# test — see `test:gate` in the root package.json. Gate membership is the
|
||||
# explicit allow-list in packages/engine/vitest.config.ts (engine-core
|
||||
# project); a flaky gate test is evicted by removing it from that list.
|
||||
gate:
|
||||
name: Gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -158,10 +86,12 @@ jobs:
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
# Same dist-artifact cache as test-shards (L1): the curated-gate guard runs
|
||||
# `vitest list`, whose config resolution can touch built dist, so it also
|
||||
# pays the cold-dist rebuild. Exact-match key on the pre-build source hash;
|
||||
# NO restore-keys (stale dist is the failure mode), NO node_modules.
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Dist-artifact cache (same contract as full-suite.yml): exact-match
|
||||
# key only, NO restore-keys (stale dist is the known failure mode,
|
||||
# FN-4232/FN-4605), NEVER node_modules (breaks Windows pnpm junctions).
|
||||
- name: Compute dist source hash
|
||||
id: dist-hash
|
||||
run: echo "hash=$(node scripts/ensure-test-artifacts.mjs --print-source-hash)" >> "$GITHUB_OUTPUT"
|
||||
@@ -185,25 +115,13 @@ jobs:
|
||||
if: steps.dist-cache.outputs.cache-hit == 'true'
|
||||
run: node scripts/ensure-test-artifacts.mjs --seed-artifact-cache
|
||||
|
||||
- name: Assert every dashboard test file is gated or skip-listed
|
||||
run: node scripts/check-test-inventory.mjs --dashboard-curated
|
||||
# Boot smoke needs the full built workspace (CLI dist is not in the
|
||||
# cache list above); cached packages make this incremental-fast.
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
# Plan U2 / R8: the engine-slow tier (src/**/*.slow.test.ts) previously ran in
|
||||
# NO automated gate — only via the local `test:full`. This job runs it and
|
||||
# asserts a non-empty execution, so a glob/config drift that silently empties
|
||||
# the tier fails CI instead of passing vacuously. Engine slow tests do real
|
||||
# git operations, so a full clone (fetch-depth: 0) is required.
|
||||
test-slow:
|
||||
name: Engine slow tier
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Boot smoke (app starts and serves)
|
||||
run: node scripts/boot-smoke.mjs
|
||||
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Run engine-slow with non-empty-execution assertion
|
||||
run: node scripts/assert-engine-slow-nonempty.mjs
|
||||
- name: Gate tests (curated engine-core + CI-shape)
|
||||
run: pnpm test:gate
|
||||
|
||||
@@ -63,11 +63,13 @@ FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace pr
|
||||
pnpm dev:ui # dashboard dev server only
|
||||
pnpm dev:hmr # dashboard API + Vite HMR UI, with no startup prebuild
|
||||
pnpm lint # lint all packages
|
||||
pnpm test # changed-only workspace tests (falls back to full suite in safety contexts)
|
||||
pnpm test:full # full workspace quality gate (clean-worktree compatible)
|
||||
pnpm test # merge-gate suite + changed-only affected tests (bounded; never full-suite)
|
||||
pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test
|
||||
pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health
|
||||
pnpm test:full # full workspace suite (explicit opt-in; clean-worktree compatible)
|
||||
pnpm build # workspace builds (excludes desktop/mobile)
|
||||
pnpm build:all # full workspace build (includes desktop/mobile)
|
||||
pnpm verify:workspace # canonical lint -> test -> build verification gate
|
||||
pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build
|
||||
pnpm typecheck # workspace typechecks
|
||||
```
|
||||
|
||||
@@ -80,20 +82,21 @@ Fusion codifies workspace verification as a deterministic contract:
|
||||
- Root test entrypoints (`pnpm test` via `scripts/test-changed.mjs` and `pnpm test:ci:shard` via `scripts/ci-test-shard.mjs`) call `scripts/ensure-test-artifacts.mjs`, which deterministically builds only missing/stale required workspace dist artifacts (`@fusion/core`, `@fusion/dashboard`, `@fusion/engine`, `@fusion/plugin-sdk`, and `@fusion-plugin-examples/{dependency-graph,hermes-runtime,openclaw-runtime,paperclip-runtime}`).
|
||||
- Package-scoped hooks now mirror this bootstrap for fresh worktrees where needed: `@fusion/dashboard` and `@fusion-plugin-examples/dependency-graph` run `pretest: node ../../scripts/ensure-test-artifacts.mjs`.
|
||||
- This includes clean states where those required dist directories are absent.
|
||||
- `pnpm verify:workspace` is the canonical pre-merge gate and runs in strict order:
|
||||
- `pnpm test:gate` is the merge gate: the curated `engine-core` suite plus the CI-shape test. CI blocks PRs on exactly Lint, Typecheck, Build, and Gate (boot smoke + `pnpm test:gate`) — see `.github/workflows/pr-checks.yml` and docs/testing.md.
|
||||
- `pnpm verify:workspace` is the deep opt-in verification (not the merge gate) and runs in strict order:
|
||||
1. `pnpm lint`
|
||||
2. `pnpm test:full`
|
||||
3. `pnpm build`
|
||||
|
||||
GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --shard <index> --total <count>` in both PR checks and manual CI, while keeping local semantics unchanged:
|
||||
GitHub Actions runs deterministic test sharding via `pnpm test:ci:shard --shard <index> --total <count>` in the non-blocking `full-suite.yml` workflow (push to main only — never a PR gate), while keeping local semantics unchanged:
|
||||
|
||||
- `pnpm test` remains changed-only local iteration.
|
||||
- `pnpm test:full` remains the canonical workspace quality gate; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`.
|
||||
- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate.
|
||||
- `pnpm test` remains gate + changed-only local iteration.
|
||||
- `pnpm test:full` remains the explicit full workspace suite; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`.
|
||||
- `pnpm verify:workspace` remains the deep opt-in lint -> test -> build verification.
|
||||
|
||||
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, auto-splitting oversized packages into virtual shard entries (`{ name, shardIndex, shardCount }`), then assigning entries in descending weight order with best-fit placement for unsplit entries (closest under-budget fit, otherwise minimum overshoot) while keeping slices of the same package on different shards when possible. Whole entries run as grouped `pnpm --filter <pkg> test` calls, and virtual entries run one-by-one via `pnpm --filter <pkg> test -- --shard <index>/<count>`. This keeps coverage reproducible while improving shard balance.
|
||||
|
||||
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, shared/root test infrastructure changes, or changed workspace paths cannot be resolved to a workspace package (fail-safe coverage behavior).
|
||||
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It runs the merge-gate suite first, then the affected set. The full suite runs only on explicit opt-in (`--full` / `pnpm test:full`); shared-infrastructure changes and unresolvable diffs widen the affected set but never escalate to an implicit full-suite run (the old escalation was the local OOM path).
|
||||
|
||||
Root test entrypoints (`pnpm test`, `pnpm test:full`, and `pnpm test:ci:shard`) now use a shared CPU-aware default worker budget instead of fixed low values. By default, Fusion sets `FUSION_TEST_TOTAL_WORKERS` to `max(4, min(12, cpuCount - 1))` and `FUSION_TEST_CONCURRENCY` to `2` (clamped to the total budget), while still honoring explicit overrides from `VITEST_MAX_WORKERS`, `FUSION_TEST_TOTAL_WORKERS`, and `FUSION_TEST_CONCURRENCY`.
|
||||
|
||||
@@ -113,7 +116,8 @@ If you add or change test entrypoints, keep this isolation guard path intact and
|
||||
|
||||
Before submitting changes, verify:
|
||||
|
||||
- [ ] `pnpm verify:workspace` — canonical lint → test → build gate
|
||||
- [ ] `pnpm test:gate` — the merge gate (curated engine-core suite + CI-shape test)
|
||||
- [ ] `pnpm verify:workspace` — deep opt-in lint → test:full → build verification
|
||||
- [ ] `pnpm typecheck` — type checking passes
|
||||
|
||||
## Realtime/SSE change note
|
||||
@@ -160,7 +164,7 @@ pnpm build:exe:all # build multi-target executables
|
||||
Default workspace verification stays lean and deterministic:
|
||||
|
||||
- `pnpm test` runs the standard suite and does **not** require Bun cross-build integration tests.
|
||||
- `pnpm verify:workspace` remains the canonical `lint -> test -> build` gate.
|
||||
- `pnpm verify:workspace` remains the deep opt-in `lint -> test -> build` verification.
|
||||
|
||||
Slow/pre-release CLI coverage is explicit and opt-in:
|
||||
|
||||
|
||||
@@ -27,12 +27,10 @@ function findCompositeSetupStep(steps: any[]) {
|
||||
return steps.find((step) => step.uses === "./.github/actions/setup-node-pnpm");
|
||||
}
|
||||
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
let compositeAction: any;
|
||||
let buildSteps: any[];
|
||||
let testShardJob: any;
|
||||
let contributingContent: string;
|
||||
let readmeContent: string;
|
||||
let cliPackageJsonContent: string;
|
||||
@@ -41,12 +39,10 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
let buildExeSuiteContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("ci.yml");
|
||||
const result = loadWorkflow("pr-checks.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed;
|
||||
buildSteps = workflow.jobs?.build?.steps ?? [];
|
||||
testShardJob = workflow.jobs?.["test-shards"];
|
||||
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
|
||||
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
|
||||
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
|
||||
@@ -64,134 +60,56 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const findBuildStepByRun = (runSnippet: string) =>
|
||||
buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("uses workflow_dispatch trigger (auto CI disabled)", () => {
|
||||
expect(workflow.on).toHaveProperty("workflow_dispatch");
|
||||
it("runs on pull requests targeting main and ONLY there", () => {
|
||||
expect(workflow.on?.pull_request?.branches).toContain("main");
|
||||
// Post-merge signal lives in full-suite.yml; the gate workflow must not
|
||||
// double-run on push (that conflates blocking and non-blocking surfaces).
|
||||
expect(workflow.on?.push).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not auto-trigger on push/pull_request", () => {
|
||||
expect(workflow.on.push).toBeUndefined();
|
||||
expect(workflow.on.pull_request).toBeUndefined();
|
||||
it("blocks PRs on exactly lint, typecheck, build, and gate", () => {
|
||||
expect(Object.keys(workflow.jobs ?? {}).sort()).toEqual(["build", "gate", "lint", "typecheck"]);
|
||||
});
|
||||
|
||||
it("pins dependency bootstrap to frozen lockfile", () => {
|
||||
const jobs = [workflow.jobs?.lint, workflow.jobs?.["test-shards"], workflow.jobs?.build];
|
||||
for (const job of jobs) {
|
||||
expect(findCompositeSetupStep(job?.steps ?? [])).toBeDefined();
|
||||
it("contains no shard matrix or full-suite invocation (demoted to full-suite.yml)", () => {
|
||||
expect(workflow.jobs?.["test-shards"]).toBeUndefined();
|
||||
expect(workflow.jobs?.["test-slow"]).toBeUndefined();
|
||||
expect(workflow.jobs?.["test-inventory-guard"]).toBeUndefined();
|
||||
expect(content).not.toContain("test:ci:shard");
|
||||
expect(content).not.toContain("run: pnpm test\n");
|
||||
expect(content).not.toContain("pnpm verify:workspace");
|
||||
});
|
||||
|
||||
it("gate job runs boot smoke and the dedicated test:gate command", () => {
|
||||
const gateSteps = workflow.jobs?.gate?.steps ?? [];
|
||||
expect(
|
||||
gateSteps.some(
|
||||
(step: any) => typeof step.run === "string" && step.run.includes("node scripts/boot-smoke.mjs"),
|
||||
),
|
||||
).toBe(true);
|
||||
// The gate must use the dedicated command — `pnpm test` routes through
|
||||
// scripts/test-changed.mjs whose selection semantics are for local runs.
|
||||
expect(
|
||||
gateSteps.some(
|
||||
(step: any) => typeof step.run === "string" && step.run.includes("pnpm test:gate"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("pins dependency bootstrap to frozen lockfile in every job", () => {
|
||||
for (const jobName of ["lint", "typecheck", "build", "gate"]) {
|
||||
expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined();
|
||||
}
|
||||
expect(content).not.toContain("run: pnpm install\n");
|
||||
expect(content).not.toContain("--no-frozen-lockfile");
|
||||
expect(compositeAction.inputs?.["install-args"]?.default).toBe("--frozen-lockfile");
|
||||
});
|
||||
|
||||
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
|
||||
expect(workflow.jobs?.lint).toBeDefined();
|
||||
expect(testShardJob).toBeDefined();
|
||||
expect(workflow.jobs?.build).toBeDefined();
|
||||
|
||||
expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
|
||||
expect(content).not.toContain("pnpm verify:workspace");
|
||||
});
|
||||
|
||||
it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => {
|
||||
expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]);
|
||||
expect(findBuildStepByRun("pnpm build")).toBeDefined();
|
||||
expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined();
|
||||
expect(findBuildStepByRun("build:exe")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps contributing docs aligned with verification and slow-lane contracts", () => {
|
||||
expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
|
||||
expect(contributingContent).toContain("`pnpm verify:workspace` is the canonical pre-merge gate");
|
||||
expect(contributingContent).toContain("1. `pnpm lint`");
|
||||
expect(contributingContent).toContain("2. `pnpm test:full`");
|
||||
expect(contributingContent).toContain("3. `pnpm build`");
|
||||
expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint");
|
||||
|
||||
expect(contributingContent).toContain("pnpm test:slow-cli");
|
||||
expect(contributingContent).toContain("test:pre-release");
|
||||
expect(contributingContent).toContain("test:extension-integration");
|
||||
});
|
||||
|
||||
it("keeps docs aligned with default and explicit build commands", () => {
|
||||
expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)");
|
||||
expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)");
|
||||
|
||||
expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)");
|
||||
expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile");
|
||||
});
|
||||
|
||||
it("includes binary build step", () => {
|
||||
expect(content).toContain("build:exe");
|
||||
});
|
||||
|
||||
it("keeps explicit gating for audited CLI integration suites", () => {
|
||||
expect(cliPackageJsonContent).toContain('"test:slow-cli"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||
|
||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
|
||||
expect(extensionSuiteContent).toContain("dist/extension.js");
|
||||
|
||||
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"');
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"');
|
||||
expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)");
|
||||
});
|
||||
|
||||
it("includes Bun setup", () => {
|
||||
expect(content).toContain("oven-sh/setup-bun");
|
||||
});
|
||||
|
||||
it("verifies binary exists after build", () => {
|
||||
expect(content).toContain("test -f packages/cli/dist/fn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("pr-checks.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
});
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("runs on pull requests targeting main", () => {
|
||||
expect(workflow.on?.pull_request?.branches).toContain("main");
|
||||
});
|
||||
|
||||
it("uses the same deterministic test sharding command as manual CI", () => {
|
||||
expect(workflow.jobs?.lint).toBeDefined();
|
||||
expect(workflow.jobs?.typecheck).toBeDefined();
|
||||
expect(workflow.jobs?.build).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4");
|
||||
expect(content).not.toContain("run: pnpm test\n");
|
||||
});
|
||||
|
||||
it("keeps lint as install + lint only, without Bun/setup build coupling", () => {
|
||||
const lintSteps = workflow.jobs?.lint?.steps ?? [];
|
||||
expect(
|
||||
@@ -229,7 +147,99 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not spend PR action minutes on a pre-test workspace build", () => {
|
||||
it("keeps contributing docs aligned with the gate contract", () => {
|
||||
expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
|
||||
expect(contributingContent).toContain("`pnpm test:gate` is the merge gate");
|
||||
expect(contributingContent).toContain("`pnpm verify:workspace` is the deep opt-in verification (not the merge gate)");
|
||||
expect(contributingContent).toContain("1. `pnpm lint`");
|
||||
expect(contributingContent).toContain("2. `pnpm test:full`");
|
||||
expect(contributingContent).toContain("3. `pnpm build`");
|
||||
expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint");
|
||||
|
||||
expect(contributingContent).toContain("pnpm test:slow-cli");
|
||||
expect(contributingContent).toContain("test:pre-release");
|
||||
expect(contributingContent).toContain("test:extension-integration");
|
||||
});
|
||||
|
||||
it("keeps docs aligned with default and explicit build commands", () => {
|
||||
expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)");
|
||||
expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)");
|
||||
|
||||
expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)");
|
||||
expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile");
|
||||
});
|
||||
|
||||
it("keeps explicit gating for audited CLI integration suites", () => {
|
||||
expect(cliPackageJsonContent).toContain('"test:slow-cli"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||
|
||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
|
||||
expect(extensionSuiteContent).toContain("dist/extension.js");
|
||||
|
||||
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"');
|
||||
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"');
|
||||
expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)");
|
||||
});
|
||||
|
||||
it("the deleted manual CI workflow stays deleted", () => {
|
||||
// ci.yml was the trigger-disabled (FN-1541) 3-shard manual workflow; the
|
||||
// merge-gate redesign removed it. Reintroducing it would resurrect a
|
||||
// second, drift-prone definition of the test pipeline.
|
||||
expect(() => loadWorkflow("ci.yml")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full suite workflow (.github/workflows/full-suite.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("full-suite.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
});
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
});
|
||||
|
||||
it("runs ONLY on push to main — never as a PR gate", () => {
|
||||
expect(workflow.on?.push?.branches).toEqual(["main"]);
|
||||
expect(workflow.on?.pull_request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the demoted tier: 4-way shards, engine slow, inventory guard", () => {
|
||||
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3, 4]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 4");
|
||||
expect(workflow.jobs?.["test-slow"]).toBeDefined();
|
||||
expect(workflow.jobs?.["test-inventory-guard"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps full clones where real-git tests need history", () => {
|
||||
const shardSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
|
||||
const slowSteps = workflow.jobs?.["test-slow"]?.steps ?? [];
|
||||
for (const steps of [shardSteps, slowSteps]) {
|
||||
expect(
|
||||
steps.some((step: any) => step.uses?.includes("actions/checkout") && step.with?.["fetch-depth"] === 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("still uploads per-shard timing artifacts for snapshot refresh", () => {
|
||||
expect(content).toContain("test-timings-shard-${{ matrix.shard }}");
|
||||
});
|
||||
|
||||
it("does not spend action minutes on a pre-test workspace build", () => {
|
||||
const testSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
|
||||
expect(
|
||||
testSteps.some(
|
||||
|
||||
@@ -299,10 +299,16 @@ describe("Workspace bootstrap script contract", () => {
|
||||
});
|
||||
|
||||
describe("Workflow YAML validity", () => {
|
||||
it("ci.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("ci.yml");
|
||||
it("pr-checks.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("pr-checks.yml");
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed.name).toBe("CI");
|
||||
expect(parsed.name).toBe("PR Checks");
|
||||
});
|
||||
|
||||
it("full-suite.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("full-suite.yml");
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed.name).toBe("Full Suite (non-blocking)");
|
||||
});
|
||||
|
||||
it("version.yml is valid YAML", () => {
|
||||
|
||||
Reference in New Issue
Block a user