From 2170d37f84d5c69d7bcbc161d402c53933c47eb7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 14:05:08 -0400 Subject: [PATCH] ci: moon concern matrix; dissolve gates; gate every leg (RIG-2700 T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse the one-job CI decision the final way the frozen decomposition plans: replace the monolithic `gates` job with a moon-owned concern matrix and gate every special-infrastructure leg on a computed affected flag, so a PR pays only for the concerns it touches and each concern reports as its own named check. A new `setup` job runs a bun/TS generator (`tools/ci-matrix/`) that reads moon's own affected closure (`moon query projects --affected --upstream deep --downstream direct` on a PR; the full set on push/schedule) and every project's `ci-group.` tag, then emits the GitHub Actions matrix plus four special-leg affected flags. moon owns discovery; the generator owns only grouping, and fails loud — naming the offending project — on any untagged or double-tagged project or coverage gap, so there is no hand-maintained YAML project enumeration to drift. The pure translation core is exhaustively unit-tested; the workflow step is a thin impure shell around it. The `moon` matrix job fans out one leg per concern group (go / bun / nix / forks) discovered at run time, `fail-fast: false`, no `continue-on-error`. An unaffected group runs as a seconds-long green placeholder so the check names stay stable and the matrix is never empty. The four special legs (pgtest, microVM, forge oracle, gtk3 e2e) each gain `needs: setup` and a job-level gate on their paired flag; the `CI` rollup requires plain success from `setup` and `moon` and accepts a special leg's skip only when its paired flag is `false` (a failed setup leaves the flag empty, so the skip is not excused and the rollup reds — fail-safe, never blanket skipped-is-green). Tag scheme uses a DOT delimiter (`ci-group.bun`), not the colon the record first drafted: moon 2.5.3 tag ids forbid a colon. `renovate-preflight` gains a `ci` aggregate task, closing a latent silent-skip where the moon sweep passed it over. `ci-matrix` is registered in `.moon/workspace.yml` so the generator's own project is discoverable and its `ci-group.bun` tag is covered by the very assertion it enforces. Refs RIG-2700 Co-authored-by: Matt Wilkinson --- .github/workflows/ci.yml | 460 ++++++++++++------ .moon/workspace.yml | 9 + agent-image/moon.yml | 1 + app-bundle/moon.yml | 1 + apps/eng-docs/moon.yml | 2 +- apps/ui/moon.yml | 2 +- bun.lock | 12 + .../ci/compass-ci-job-decomposition/design.md | 24 +- forks/oh-my-pi/moon.yml | 2 +- go/moon.yml | 2 +- guest-image/moon.yml | 1 + moon.yml | 2 + packages/compass-agent/moon.yml | 2 +- packages/compass-client/moon.yml | 2 +- proto/moon.yml | 2 +- tools/agent-image-env-gate/moon.yml | 2 +- tools/ci-matrix/biome.json | 3 + tools/ci-matrix/index.test.ts | 373 ++++++++++++++ tools/ci-matrix/index.ts | 295 +++++++++++ tools/ci-matrix/moon.yml | 27 + tools/ci-matrix/package.json | 14 + tools/ci-matrix/tsconfig.json | 19 + tools/cx-token-gate/moon.yml | 2 +- tools/design-ledger-gate/moon.yml | 2 +- tools/forge-linear-token/moon.yml | 2 +- tools/microvm-boot-test/moon.yml | 2 +- tools/orion-ref-gate/moon.yml | 2 +- tools/renovate-preflight/moon.yml | 7 +- tools/renovate/moon.yml | 2 +- tools/stamp-gate/moon.yml | 2 +- tools/toolchain/moon.yml | 2 +- 31 files changed, 1115 insertions(+), 165 deletions(-) create mode 100644 tools/ci-matrix/biome.json create mode 100644 tools/ci-matrix/index.test.ts create mode 100755 tools/ci-matrix/index.ts create mode 100644 tools/ci-matrix/moon.yml create mode 100644 tools/ci-matrix/package.json create mode 100644 tools/ci-matrix/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54b57c2b..a91e3e97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,55 @@ # The remote gate for every pull request and every push to main, plus a nightly # full sweep of main. # -# ONE JOB, NOT A MATRIX — the central structural decision, made deliberately. +# A MATRIX OF NAMED CONCERN GROUPS, NOT ONE JOB — this reverses the earlier +# single-job decision recorded here. That decision rejected a matrix on one +# ground: a matrix could only be built by ENUMERATING projects (or tasks) in +# YAML, a second source of truth for something .moon/workspace.yml already owns, +# and such a list goes stale silently (a fork tree once shipped a functional-CI +# registration with no entry in the project map, so it read as covered and gated +# nothing). That ground no longer applies, because the matrix is no longer +# enumerated in YAML. moon still owns project DISCOVERY: the `setup` job below +# runs `moon query projects` (the affected closure on a PR, the full set on +# push/schedule) and a generator (tools/ci-matrix) translates that live answer +# into the matrix the `moon` job fans out over. The generator owns only +# GROUPING — bucketing each discovered project by its one `ci-group.` tag +# — and that grouping is guarded so it cannot silently drift: the generator +# fails loud (naming the offending project id) on any untagged project +# (zero-untagged), any project carrying two ci-group tags (disjointness), and +# any coverage gap. A new project with a `ci` task is picked up the moment it is +# registered and tagged — no matrix entry to forget — so the arriving oh-my-pi +# fork still needs no edit to this file, the same property the one-job design +# prized, now with per-group named checks instead of one opaque `gates` status. # -# A matrix fanning each moon task into its own job would give per-task check -# names and crisper failure attribution. It was rejected on one decisive -# ground: it can only be built by enumerating projects (or tasks) in YAML, and -# a project list in a workflow is a second source of truth for something -# .moon/workspace.yml already owns. That list goes stale silently. It has -# already happened in this repo's history — a fork tree shipped a functional-CI -# registration with no entry in the project map, so it read as covered and -# gated nothing. A YAML enumeration recreates exactly that failure at the CI -# layer: add a project, forget the matrix entry, and the gate goes quiet about -# it while still reporting green. moon discovers projects from -# .moon/workspace.yml, so a new project with a `ci` task is picked up the moment -# it is registered — no matrix entry to forget, which is why the arriving -# oh-my-pi fork needs no edit to this file. +# The `gates` display name is gone. Each concern group runs as its own matrix +# leg named `Moon battery ()` (go / bun / nix / forks today), so a +# failure is attributed to a named check rather than to a line in one job's log. +# The four special legs that already needed an environment the bare moon battery +# has no business realizing — pgtest, microvm, forge-oracle, gtk3-e2e — remain +# their own peer jobs. A single required `CI` rollup (below) still fronts the +# whole set, so branch protection requires one check regardless of the fan-out. # -# The costs of one job are real and accepted: a single check name to require, -# and the failing task named in the log rather than in the check list. moon -# already prints the failing target and only the failing target's output, so -# attribution costs one click, not a bisect. +# AFFECTED ON PRs, FULL ON MAIN — now per leg. A pull request runs only what it +# affects: `setup` computes the affected closure once and emits, per group, the +# affected members' `:ci` targets (an unaffected group's leg no-ops) plus a +# per-special-leg affected flag (pgtest/microvm/forge/gtk3), so a Go, UI, or +# docs PR never pays for the vendored forks' nix builds (the dominant cost, and +# the reason the forks leg's timeout is 90m). The tradeoff is real and named: +# affected detection trusts each task's `inputs` globs to be complete, so an +# inputs glob that misses a file would skip a task that should have run. The +# backstop is that main is never gated by affected: every push to main AND a +# nightly `schedule` run the FULL set — every group, every special leg, no +# filter (setup emits every group with run:true and every flag true). So an +# inputs gap a PR skipped is caught the moment the change lands on main (named +# and attributable), not left to hide; the nightly sweep re-checks main even on +# a quiet day. Fast affected path on PRs, exhaustive sweep on everything that +# reaches main. # -# AFFECTED ON PRs, FULL ON MAIN. A pull request runs `moon ci :ci`, which runs -# only the projects the PR affects — a Go, UI, or docs PR never pays for the -# vendored forks' nix builds (the dominant cost, and the reason the CI job's -# timeout is 90m). The tradeoff is real and named: affected detection trusts -# each task's `inputs` globs to be complete, so an inputs glob that misses a -# file would skip a task that should have run — the same silent-skip class the -# matrix rationale above rejects. The backstop is that main is never gated by -# affected: every push to main AND a nightly `schedule` run the FULL `moon run -# :ci` — every task, every project, no filter. So an inputs gap a PR skipped is -# caught the moment the change lands on main (named and attributable), not left -# to hide; the nightly sweep re-checks main even on a quiet day. Fast affected -# path on PRs, exhaustive sweep on everything that reaches main. -# -# pgtest — the real-Postgres suites — now runs as its own peer job (below), -# behind the `CI` rollup, rather than as a step in the moon `gates` job. It was -# peeled out so a Postgres-service flake or the slow `-race` suite no longer -# serializes behind the whole moon battery: the two lanes fail independently and -# a service-container flake reds only `pgtest` (a re-run clears it), not the +# pgtest — the real-Postgres suites — runs as its own peer job (below), behind +# the `CI` rollup, rather than as a step in the moon battery. It was peeled out +# so a Postgres-service flake or the slow `-race` suite no longer serializes +# behind the whole battery: the two lanes fail independently and a +# service-container flake reds only `pgtest` (a re-run clears it), not the # hermetic gate. The suites are build-tagged `pgtest`, so the moon battery's `go # test ./...` never compiles them — the pgtest job is the only thing that runs # them. It joins the `CI` rollup's `needs`, so there is still one required check. @@ -99,25 +109,131 @@ permissions: contents: read jobs: - gates: - name: Gates (moon) + setup: + name: Setup (concern matrix) + runs-on: ubuntu-latest + # The discovery + grouping stage: run `moon query projects` (affected on a + # PR, full on push/schedule) and translate the live answer into the matrix + # the `moon` job fans out over, plus the four special-leg affected flags. + # moon owns discovery; the generator owns only grouping, guarded loud on any + # untagged / double-tagged project or coverage gap. Same no-op-`edited` guard + # every gate job carries. + if: >- + github.event_name != 'workflow_dispatch' && + (github.event_name != 'pull_request' || + github.event.action != 'edited' || + github.event.changes.base != null) + timeout-minutes: 15 + env: + # Same short temp root as every gate job — see the moon battery below for + # the sun_path rationale. Harmless here; kept for consistency. + TMPDIR: /tmp + outputs: + # The matrix the `moon` job fans out over (one entry per group that exists + # in the workspace), plus the four special-leg affected flags each gated + # job reads to decide whether a PR must run it. The full set is emitted on + # push/schedule (every group run:true, every flag true) — the full-sweep + # backstop lives in the generator, not here. + matrix: ${{ steps.gen.outputs.matrix }} + pgtest_affected: ${{ steps.gen.outputs.pgtest_affected }} + microvm_affected: ${{ steps.gen.outputs.microvm_affected }} + forge_affected: ${{ steps.gen.outputs.forge_affected }} + gtk3_affected: ${{ steps.gen.outputs.gtk3_affected }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # fetch-depth 0 gives the base branch history the generator's affected + # closure + the forge/gtk3 changed-paths diff need. moon reads the base + # from GITHUB_BASE_REF and diffs against it; the generator's own path + # detection diffs base...HEAD — both need real history, not a shallow + # single-ref checkout. + fetch-depth: 0 + + - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 + with: + # The forks' functional CI is `nix build` of their own flakes, and the + # whole toolchain — language runtimes AND the nixpkgs tools — is built + # from nix below. Flakes are needed for the former; nix-command for both. + # + # The substituters below are declared here rather than delegated via + # `accept-flake-config = true`. That setting makes nix accept the + # `nixConfig` of ANY flake it evaluates, and the RigelBuild/devenv + # flake the build fetches carries exactly such a block — so a PR could add its own substituter + # AND its own trusted public key, and CI would fetch and run binaries + # signed by the attacker's key, with signature verification satisfied + # by the key the same PR supplied. Naming the two caches the forks + # legitimately use keeps that trust in a reviewed file: adding a third + # is a change to this workflow, not to a vendored tree. + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://devenv.cachix.org https://cachix.cachix.org + extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= + + - name: Put the language toolchains on PATH + # Phase one of the two-phase bootstrap — the generator needs only bun and + # moon on PATH (it runs `moon query projects` under bun), so setup carries + # phase one alone and skips the heavier phase-two nixpkgs-tools step. The + # language runtimes (bun/node/moon/go) come from nix, not `setup-*` + # actions: gate-tools.nix's `langs` output resolves the identical + # derivations the dev shell does — bun/node/moon from + # tools/toolchain/toolchain-tools.nix, go from the go-overlay applied to + # the devenv.lock-pinned nixpkgs — so CI runs the pinned versions + # byte-for-byte. `langs` is a closed set, so it needs no `--arg attrs` + # (the head defaults it). + run: | + stores=$(nix eval --json -f tools/toolchain/gate-tools.nix langs \ + | jq -r '.[].store') + # Fail closed locally rather than leaning on the absence of a + # root-level flake.nix: with no installables `nix build` would build a + # default package if one existed, so an empty `langs` must error here. + [ -n "$stores" ] || { + echo "::error::gate-tools.nix langs produced no store paths" + exit 1 + } + nix build --no-link $stores + for store in $stores; do + echo "$store/bin" >>"$GITHUB_PATH" + done + + - name: Generate the concern matrix + id: gen + # The discovery + grouping edge: runs `moon query projects` (affected + # closure on a PR via GITHUB_BASE_REF; full set on push/schedule) and its + # own base...HEAD changed-paths diff for the forge/gtk3 detection, then + # translates that into the matrix + the four affected flags written to + # $GITHUB_OUTPUT (mapped up into this job's `outputs:` above). The pure + # translation core is exhaustively unit-tested; this invocation is the + # thin impure shell around it. It fails loud (exit 1, `::error::`) on any + # untagged / double-tagged project or coverage gap — the fail-loud posture + # that lets moon own discovery without a stale YAML enumeration. + run: bun run tools/ci-matrix/index.ts + + moon: + name: Moon battery (${{ matrix.group }}) runs-on: ubuntu-latest - # Skip only the no-op case of the `edited` trigger above: a PR title/body - # edit that did NOT move the base. `changes.base` is present on the payload - # only when the base branch was re-pointed, so a real base re-point still - # runs (the whole reason `edited` is in the trigger set), while a title/body - # edit is a no-op. push and schedule are not `pull_request` events, so the - # first clause lets them through unconditionally. `workflow_dispatch` is - # excluded: it exists solely to drive the regen-forge-fixtures job, which - # carries its own `if:` — a fixture-regen dispatch has no business spinning - # up the full 90-minute gate battery, so this job stays off it. + needs: setup + # One leg per concern group, discovered at run time from setup's matrix. No + # YAML project enumeration — the group set is whatever `ci-group.*` tags + # exist in the workspace, translated by the generator. `fail-fast: false` so + # one group's failure does not cancel the others (each is an independently + # attributable named check). NO `continue-on-error`: a leg's failure must red + # its check and, through the rollup, the required `CI` status. + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.setup.outputs.matrix) }} + # Same no-op-`edited` guard every gate job carries (see setup): a title/body + # edit that did not move the base is a no-op; push/schedule pass through; + # workflow_dispatch stays off (regen-only). if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.base != null) - # The vendored forks build through nix (devenv's Rust crates via its flake's - # fenix pin), so this is a long job by construction. + # The forks group builds through nix (devenv's Rust crates via its flake's + # fenix pin), the dominant cost, so 90m is the ceiling every leg shares for + # now. A later dedup can give each group its own tighter ceiling; the forks + # leg is the one that actually needs 90. timeout-minutes: 90 env: # A short temp root, because AF_UNIX sun_path is 108 bytes and the Go @@ -132,13 +248,20 @@ jobs: # be redesigned around one runner's choice of temp root. TMPDIR: /tmp steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Every step is gated on `matrix.run == 'true'`: an unaffected group is a + # placeholder leg (run:'false', empty targets) that the generator emits to + # keep the matrix non-empty and the check names stable, so its leg spins up + # and no-ops rather than never appearing. A running leg does the full + # two-phase bootstrap the moon battery does. + - if: matrix.run == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The drift gate's `git diff --no-index` needs real git; a shallow # single-ref checkout does not provide it. fetch-depth: 0 - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 + - if: matrix.run == 'true' + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: # The forks' functional CI is `nix build` of their own flakes, and the # whole toolchain — language runtimes AND the nixpkgs tools — is built @@ -159,6 +282,7 @@ jobs: extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= - name: Put the language toolchains on PATH + if: matrix.run == 'true' # Phase one of the two-phase bootstrap. The language runtimes # (bun/node/moon/go) come from nix, not `setup-*` actions: gate-tools.nix's # `langs` output resolves the identical derivations the dev shell does — @@ -186,6 +310,7 @@ jobs: done - name: Put the dev shell's nixpkgs tools on PATH + if: matrix.run == 'true' # Phase two. buf, protoc, the Go analysis battery, biome and markdownlint # have no `setup-*` action that could reproduce a nixpkgs pin. Rather than # pick a close-enough release from some other channel and hope, CI @@ -215,40 +340,34 @@ jobs: echo "PLAYWRIGHT_CHROMIUM_PATH=$chromium/bin/chromium" >>"$GITHUB_ENV" - name: Toolchain parity - # Runs BEFORE the gate, though `:ci` schedules it again (it takes under a - # second). A toolchain skew invalidates every result the gate is about to - # produce, so finding out first is worth running it twice. + if: matrix.run == 'true' + # Runs BEFORE the battery, though `:ci` schedules it again (it takes under + # a second). A toolchain skew invalidates every result the battery is + # about to produce, so finding out first is worth running it twice. run: bun tools/toolchain/parity.ts - - name: CI (affected) - # Pull requests only: run only what the PR affects. moon reads the base - # from GITHUB_BASE_REF (the PR's target branch) via the ci_env crate and - # diffs against it — see the AFFECTED section in the header for the - # tradeoff and the backstop. Restricted to `pull_request` deliberately: - # on a push GITHUB_BASE_REF is unset, so moon would fall back to - # comparing vcs.defaultBranch against HEAD — which on a push to main is - # main against its own tip, an empty diff that runs nothing and reports a - # misleading green. So affected is the fast PR path; every landing on - # main gets the full sweep below. - if: github.event_name == 'pull_request' - run: moon ci :ci - - - name: CI (full sweep) - # Every push to main and the nightly schedule: the whole battery, - # unconditionally. This is what makes the affected PR gate safe — an - # incomplete `inputs` glob that let a task be skipped on a PR is caught - # here the moment the change lands on main (and again nightly), named and - # attributable, not left to hide indefinitely. `moon run :ci`, not `moon - # ci`: affected detection would find nothing to do on a push to main. - if: github.event_name != 'pull_request' - run: moon run :ci + - name: Moon battery + if: matrix.run == 'true' + # `moon run `, NOT `moon ci`: setup already computed the affected + # set and this leg's `matrix.targets` are exactly the affected members' + # `:ci` targets for this group (full set on push/schedule). Re-running + # `moon ci` here would recompute affected — redundant on a PR and, on a + # push where GITHUB_BASE_REF is unset, an empty-diff no-op that runs + # nothing. Passing the precomputed targets keeps discovery in one place + # (setup) and makes each leg run exactly its share. `matrix.targets` is a + # JSON array; `join(...)` renders it as the space-separated argument list + # `moon run` expects (a bare `${{ matrix.targets }}` would inline the raw + # JSON, not a target list). A running leg always has >=1 target, so this + # is never an empty `moon run`. + run: moon run ${{ join(matrix.targets, ' ') }} - name: Retrospect - # Collapse the single job's flat task fan-out into per-task sections in - # the Actions log, so a failure is one expand instead of a scroll. Reads + if: matrix.run == 'true' && (success() || failure()) + # Collapse this leg's flat task fan-out into per-task sections in the + # Actions log, so a failure is one expand instead of a scroll. Reads # .moon/cache/{ci,run}Report.json — no token, no added permissions, so # `permissions: contents: read` above is untouched. `success() || - # failure()` so it still renders when the gate failed. + # failure()` so it still renders when the battery failed. # # This is third-party JS that runs on every event, fork PRs included, so # the SHA pin below IS its trust anchor — the same posture the pgtest @@ -257,21 +376,27 @@ jobs: # and only reads and prints local cache files, so a fork PR cannot turn it # into an exfiltration or write path. Bump the pin deliberately (it rides # the same dependabot config as the other pinned actions), never drift it. - if: success() || failure() uses: appthrust/moon-ci-retrospect@631a1e667f96e8ca893eab69f03e425f7fbfdc2b # v2.1.0 pgtest: name: pgtest (real-Postgres suites) runs-on: ubuntu-latest + needs: setup # Peeled out of the moon gate so a Postgres-service flake or a slow -race - # suite no longer serializes behind the whole battery. Runs unconditionally - # on every gate event; a later task gates it on the Go affected-closure so an - # unaffected PR pays zero. Same no-op-`edited` guard the other work jobs carry. + # suite no longer serializes behind the whole battery. The job-level `if:` + # ANDs the shared no-op-`edited` guard with the pgtest affected gate: on a PR + # it runs only when setup's closure contains compass-go (pgtest_affected), + # and always on push/schedule (setup emits the flag 'true' there). A failed + # setup leaves the output empty → `'' == 'true'` is false → the job skips, + # and the rollup turns that skip red because the paired flag is not 'false'. + # The in-step guards stay as defense-in-depth. if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.action != 'edited' || - github.event.changes.base != null) + github.event.changes.base != null) && + (github.event_name != 'pull_request' || + needs.setup.outputs.pgtest_affected == 'true') timeout-minutes: 20 env: # The Real-Postgres suites bind AF_UNIX sockets under t.TempDir(); the @@ -428,18 +553,22 @@ jobs: microvm: name: microvm (KVM boot suite) runs-on: ubuntu-latest + needs: setup # Peeled out of the moon gate so the KVM boot suite runs as its own lane, in # parallel with the moon battery and the other peer jobs, behind the CI - # rollup. Runs unconditionally on every gate event; a later task gates it on - # the microVM affected-closure so an unaffected PR pays zero. No privileged - # container — the boot leg runs rootless on a bare runner with the udev + - # sysctl relaxations below (design T2 / §E-D2). Same no-op-`edited` guard the - # other work jobs carry. + # rollup. The job-level `if:` ANDs the shared no-op-`edited` guard with the + # microVM affected gate: on a PR it runs only when setup's closure contains + # compass-go or compass-guest-image (microvm_affected), and always on + # push/schedule. No privileged container — the boot leg runs rootless on a + # bare runner with the udev + sysctl relaxations below (design T2 / §E-D2). + # The in-step guards stay as defense-in-depth. if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.action != 'edited' || - github.event.changes.base != null) + github.event.changes.base != null) && + (github.event_name != 'pull_request' || + needs.setup.outputs.microvm_affected == 'true') timeout-minutes: 30 env: # microVM suites bind AF_UNIX sockets under t.TempDir(); the runner's @@ -650,16 +779,22 @@ jobs: forge-oracle: name: forge-oracle (live-contract oracle) runs-on: ubuntu-latest + needs: setup # Peeled out of the moon gate so the secret-bearing live oracle runs as its # own lane behind the CI rollup; the tri-event + same-repo-head guards stay - # on the steps as defense-in-depth. Runs its own bootstrap to reach the - # in-step forge-affected detection until T5 hoists that into setup and adds a - # job-level gate. No privileged container. + # on the steps as defense-in-depth. The job-level `if:` ANDs the shared + # no-op-`edited` guard with the forge affected gate: on a PR the job runs + # only when setup found the forge surface affected (forge_affected — the + # go/internal/forge/** or ci.yml change), and always on push/schedule. The + # in-step forge_affected detection + tri-event/same-repo-head guards stay as + # defense-in-depth. No privileged container. if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.action != 'edited' || - github.event.changes.base != null) + github.event.changes.base != null) && + (github.event_name != 'pull_request' || + needs.setup.outputs.forge_affected == 'true') timeout-minutes: 30 env: TMPDIR: /tmp @@ -899,17 +1034,21 @@ jobs: gtk3-e2e: name: gtk3-e2e (multi-window native app gate) runs-on: ubuntu-latest + needs: setup # Peeled out of the moon gate into its own lane behind the CI rollup; the # native GTK3/WebKit app gate realizes a heavy out-of-band WebKitGTK/xvfb - # closure the bare moon gate has no business building. The in-step affected - # guard stays as defense-in-depth; runs its own bootstrap to reach that guard - # until T5 hoists the affected detection into setup and adds a job-level - # gtk3_affected gate. No privileged container. + # closure the bare moon gate has no business building. The job-level `if:` + # ANDs the shared no-op-`edited` guard with the gtk3 affected gate: on a PR + # it runs only when setup found go/cmd/compass-app/ changed (gtk3_affected), + # and always on push/schedule. The in-step affected guard stays as + # defense-in-depth. No privileged container. if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || github.event.action != 'edited' || - github.event.changes.base != null) + github.event.changes.base != null) && + (github.event_name != 'pull_request' || + needs.setup.outputs.gtk3_affected == 'true') timeout-minutes: 30 env: TMPDIR: /tmp @@ -1083,7 +1222,7 @@ jobs: dogfood-e2e: name: Dogfood e2e (deterministic full-stack tier) runs-on: ubuntu-latest - # A DEDICATED job in a PRIVILEGED container, split off from `gates`, for one + # A DEDICATED job in a PRIVILEGED container, split off from the moon battery, for one # reason: the image-affected seed path. On a PR that changes the image # closure the gate builds the agent image from THIS tree and copies it into # local containers-storage (`nix run …#devenv -- container copy agent`, @@ -1094,9 +1233,9 @@ jobs: # pulled-image legs execute), but the copy-to-storage does not. A privileged # container is the reliable way to grant the userns caps that copy needs # (the redhat-actions / travier pattern), so the from-tree seed runs here - # rather than in `gates`. + # rather than in the moon battery. # - # It is its OWN job, not `gates` wrapped in a container, so `gates` stays on + # It is its OWN job, not that battery wrapped in a container, so the moon battery stays on # the bare runner: wrapping the whole job would flip its Postgres service # from 127.0.0.1 to the `postgres` service hostname (containerized jobs reach # services by label) and break the Real-Postgres step's DSN. This tier needs @@ -1116,8 +1255,8 @@ jobs: # container sandbox withholds. image: quay.io/podman/stable@sha256:663e0dbf407987b7db3f20d3588c283a8228db17b282d2029a482d4d47e36964 options: --privileged - # Mirror the `gates` guard: skip the no-op `edited` (title/body) case, and - # stay off `workflow_dispatch` (regen-only — see the gates guard). + # Mirror the moon battery's guard: skip the no-op `edited` (title/body) case, and + # stay off `workflow_dispatch` (regen-only — see the moon job's guard). if: >- github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || @@ -1125,7 +1264,7 @@ jobs: github.event.changes.base != null) timeout-minutes: 60 env: - # Same short temp root as `gates`: the e2e suite binds AF_UNIX sockets + # Same short temp root as the moon battery: the e2e suite binds AF_UNIX sockets # under t.TempDir() and the 108-byte sun_path cap needs the budget. TMPDIR: /tmp # The rootless runtime dir for the podman user. Without it, containers @@ -1291,7 +1430,7 @@ jobs: # build-users-group =) AND the podman user's own ~/.config/nix/nix.conf # (so `nix eval`/`nix build` in the steps below see nix-command + flakes; # the system /etc/nix/nix.conf the installer writes is not consulted for a - # single-user install run as a normal user). Mirrors the `gates` + # single-user install run as a normal user). Mirrors the moon job's # install-nix-action config exactly — the SAME two cache substituters + # trusted keys the vendored forks use, never `accept-flake-config` (which # would trust a PR-supplied substituter + key). @@ -1302,7 +1441,7 @@ jobs: extra-substituters = https://devenv.cachix.org https://cachix.cachix.org extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= NIXCONF - # Pin the installer by content hash, not just version+TLS: the gates + # Pin the installer by content hash, not just version+TLS: the moon # job gets its nix from a SHA-pinned action, so this bare curl must not # be the one unverified fetch feeding a --privileged job that then holds # passwordless sudo and builds+loads the agent image. Fail closed if the @@ -1317,8 +1456,8 @@ jobs: - name: Put the language toolchains on PATH # The dogfood seed's `moon query tasks --affected` and the e2e `go test` - # need the same nix-pinned toolchain the gates job builds. Phase one: - # bun/node/moon/go from gate-tools.nix's `langs` (see the gates job for + # need the same nix-pinned toolchain the moon job builds. Phase one: + # bun/node/moon/go from gate-tools.nix's `langs` (see the moon job for # the two-phase rationale). Duplicated here because this is a separate # job with its own runner filesystem. run: | @@ -1464,7 +1603,7 @@ jobs: # tier alone — see the record # docs/designs/product/compass-forge-integration-testing. # - # Same capture-replay-exit shape as the gates job's suites: redirect (not + # Same capture-replay-exit shape as the moon job's suites: redirect (not # a `| tee` pipeline, whose exit status is tee's 0 and would swallow a # FAIL), replay the log, then exit on go test's own status. `|| rc=$?` # because the step runs under `bash -e`. The 20m timeout is finite @@ -1529,7 +1668,7 @@ jobs: # matters: when the RUN is cancelled — every push to a ref supersedes the # prior in-flight run via the `cancel-in-progress` concurrency above, # which cancels both work jobs — `always()` would STILL run this rollup, - # see `needs.gates.result == 'cancelled'`, and red the required `CI` + # see `needs.moon.result == 'cancelled'`, and red the required `CI` # check on that superseded commit. On a busy main that is a false red on # nearly every intermediate merge. `!cancelled()` instead SKIPS the # rollup on a cancelled run, so a superseded commit's `CI` check is @@ -1556,7 +1695,7 @@ jobs: # `!cancelled()` must be parenthesized against the OR group: `&&` binds tighter # than `||`, so without the parens the trailing `|| changes.base != null` would # detach from the AND. - needs: [gates, dogfood-e2e, pgtest, microvm, forge-oracle, gtk3-e2e] + needs: [setup, moon, dogfood-e2e, pgtest, microvm, forge-oracle, gtk3-e2e] if: >- !cancelled() && github.event_name != 'workflow_dispatch' && ( @@ -1567,30 +1706,77 @@ jobs: steps: - name: Require every work job to have succeeded # `needs..result` is one of success | failure | cancelled | skipped. - # Only `success` passes. This step runs only when the rollup's own `if` - # let it (see above): a cancelled RUN skips the whole rollup, and the - # no-op-`edited` case skips it too. So the only non-success that can - # actually reach this step is a work-job `failure` or a per-job - # `cancelled` (a work-job `timeout-minutes`). A `skipped` result cannot - # reach here: `gates`/`dogfood-e2e`/`pgtest`/`microvm` have no `needs`, so their sole skip - # path is the edited-guard — which skips this rollup through the identical - # guard. The `!= success` check below still covers `skipped` as a - # belt-and-suspenders backstop, but no `needs` relationship in this - # workflow can produce it. Any failure or per-job cancellation reds the - # one required check. + # This step runs only when the rollup's own `if` let it (see above): a + # cancelled RUN skips the whole rollup, and the no-op-`edited` case skips + # it too. + # + # `setup` and `moon` are UNCONDITIONALLY required: they run on every gate + # event, so only plain `success` passes for them — a failure, a per-job + # cancellation (timeout), or a skip all red the required check. `moon`'s + # result is the matrix job's aggregate across its legs: any leg failing + # makes `needs.moon.result` non-success. + # + # The four special legs are affected-GATED at the job level, so on a PR + # that legitimately does not touch their surface they SKIP — and a skip + # there is correct, not a failure. But a skip is only accepted when its + # PAIRED setup flag says the leg was meant to skip: `result == 'skipped'` + # passes ONLY when `needs.setup.outputs. == 'false'`. This is + # fail-safe against a broken setup: a failed `setup` leaves its outputs + # EMPTY, so the flag reads '' — `'' != 'false'`, the skipped leg is NOT + # excused, and the rollup reds. Never a blanket skipped-is-green: a leg + # that skipped for any reason other than its own flag being 'false' + # (setup failure, a spurious skip) reds the required check. run: | - gates='${{ needs.gates.result }}' + setup='${{ needs.setup.result }}' + moon='${{ needs.moon.result }}' dogfood='${{ needs.dogfood-e2e.result }}' pgtest='${{ needs.pgtest.result }}' microvm='${{ needs.microvm.result }}' forge='${{ needs.forge-oracle.result }}' gtk3='${{ needs.gtk3-e2e.result }}' - echo "gates=$gates dogfood-e2e=$dogfood pgtest=$pgtest microvm=$microvm forge-oracle=$forge gtk3-e2e=$gtk3" - if [ "$gates" != "success" ] || [ "$dogfood" != "success" ] || [ "$pgtest" != "success" ] || [ "$microvm" != "success" ] || [ "$forge" != "success" ] || [ "$gtk3" != "success" ]; then - echo "::error::a required work job did not succeed (gates=$gates, dogfood-e2e=$dogfood, pgtest=$pgtest, microvm=$microvm, forge-oracle=$forge, gtk3-e2e=$gtk3)" + pgtest_flag='${{ needs.setup.outputs.pgtest_affected }}' + microvm_flag='${{ needs.setup.outputs.microvm_affected }}' + forge_flag='${{ needs.setup.outputs.forge_affected }}' + gtk3_flag='${{ needs.setup.outputs.gtk3_affected }}' + echo "setup=$setup moon=$moon dogfood-e2e=$dogfood pgtest=$pgtest microvm=$microvm forge-oracle=$forge gtk3-e2e=$gtk3" + echo "flags: pgtest=$pgtest_flag microvm=$microvm_flag forge=$forge_flag gtk3=$gtk3_flag" + rc=0 + # setup and moon must plainly succeed. + if [ "$setup" != "success" ]; then + echo "::error::setup did not succeed (result=$setup)" + rc=1 + fi + if [ "$moon" != "success" ]; then + echo "::error::the moon battery did not succeed (result=$moon)" + rc=1 + fi + # dogfood-e2e is not affected-gated — plain success required. + if [ "$dogfood" != "success" ]; then + echo "::error::dogfood-e2e did not succeed (result=$dogfood)" + rc=1 + fi + # Each gated special leg: success always passes; skipped passes ONLY + # when its paired setup flag is 'false' (a failed setup → empty flag → + # '' != 'false' → reds, never blanket skipped-is-green). + require_gated() { + local name="$1" result="$2" flag="$3" + if [ "$result" = "success" ]; then + return 0 + fi + if [ "$result" = "skipped" ] && [ "$flag" = "false" ]; then + return 0 + fi + echo "::error::$name did not succeed (result=$result, affected flag=$flag) — skipped is accepted only when its setup flag is 'false'" + rc=1 + } + require_gated pgtest "$pgtest" "$pgtest_flag" + require_gated microvm "$microvm" "$microvm_flag" + require_gated forge-oracle "$forge" "$forge_flag" + require_gated gtk3-e2e "$gtk3" "$gtk3_flag" + if [ "$rc" != 0 ]; then exit 1 fi - echo "all work jobs succeeded" + echo "all required work jobs succeeded (or correctly skipped per their setup flag)" regen-forge-fixtures: name: Regenerate forge golden fixtures @@ -1599,7 +1785,7 @@ jobs: # (TestLiveUpdateFixtures) rewrites go/internal/forge/testdata/ against the # real GitHub + Linear testbed — whose PATs live only in Actions secrets, so # a local `-update` is impossible — then opens a BOT PR carrying the rewritten - # fixtures for human review. It is a SEPARATE job, never a step in `gates`: + # fixtures for human review. It is a SEPARATE job, never a step in the moon battery: # gating on `workflow_dispatch` keeps it off every normal gate event, so the # required `CI` rollup above (which does not `needs:` this job) is untouched. if: github.event_name == 'workflow_dispatch' @@ -1611,32 +1797,32 @@ jobs: pull-requests: write timeout-minutes: 90 env: - # Same short temp root as `gates`: the live suite binds AF_UNIX sockets + # Same short temp root as the moon battery: the live suite binds AF_UNIX sockets # under t.TempDir() and the 108-byte sun_path cap needs the budget. TMPDIR: /tmp steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history so the create-pull-request action can branch and diff - # cleanly, matching the gates checkout. + # cleanly, matching the moon job's checkout. fetch-depth: 0 - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: - # The SAME two-cache trust posture as the gates job — the two + # The SAME two-cache trust posture as the moon job — the two # substituters the vendored forks use plus their trusted keys, never # `accept-flake-config` (which would trust a PR-supplied substituter + - # key). See the gates install-nix-action step for the full rationale. + # key). See the moon job's install-nix-action step for the full rationale. extra_nix_config: | experimental-features = nix-command flakes extra-substituters = https://devenv.cachix.org https://cachix.cachix.org extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM= - name: Put the language toolchains on PATH - # Phase one of the two-phase bootstrap, identical to the gates job: the + # Phase one of the two-phase bootstrap, identical to the moon job: the # language runtimes (bun/node/moon/go) from gate-tools.nix's `langs`, built # from the devenv.lock pin so this lane runs the same toolchain the gate - # does. See the gates job for the two-phase rationale. + # does. See the moon job for the two-phase rationale. run: | stores=$(nix eval --json -f tools/toolchain/gate-tools.nix langs \ | jq -r '.[].store') @@ -1659,7 +1845,7 @@ jobs: echo "$out/bin" >>"$GITHUB_PATH" - name: Mint the forge live-oracle's Linear app token - # Same per-run mint as the gates-job oracle (RIG-2423): the regen lane's + # Same per-run mint as the forge-oracle job's oracle (RIG-2423): the regen lane's # `-update` capture drives the SAME live Linear legs, which authenticate # as the OAuth app (client_credentials), so it needs a fresh app-actor # token too. No `if:` guard here — the whole job is workflow_dispatch-only @@ -1675,7 +1861,7 @@ jobs: - name: Regenerate the forge golden fixtures against the live testbed working-directory: go env: - # The SAME T2 env contract as the live-contract oracle in gates: the + # The SAME T2 env contract as the live-contract oracle in forge-oracle: the # GitHub live legs gate on the LIVEGITHUB_* trio, the Linear legs on # LINEAR_FORGE (+ team). The LIVEGITHUB_* trio + LINEAR_FORGE_TEAM come # from Actions secrets; LINEAR_FORGE is the app-actor token the mint diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 6cb42ebf..be6ef492 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -103,6 +103,15 @@ projects: # so its typecheck + unit test run in the moon-driven CI sweep — unregistered, # moon never discovers it and the mint's pure core ships untested. forge-linear-token: 'tools/forge-linear-token' + # The CI concern-matrix generator (RIG-2700): translates moon's affected + # closure + every project's `ci-group.*` tag into the per-group matrix the + # `moon` battery fans out over, plus the four special-leg affected flags. + # Registered so its own typecheck + unit tests ride the moon-driven CI sweep, + # AND — since moon discovers projects only from this explicit map — so the + # generator can see itself: its own `ci-group.bun` tag must appear in `moon + # query projects`, or the generator's zero-untagged assertion never covers the + # very tool that enforces it. + ci-matrix: 'tools/ci-matrix' # Vendored upstream fork subtrees (forks/, SEA-1512). Each carries its # own nix-driven build as functional CI — so a fork-only change is gated by # the fork's own suite — while its tree stays exempt from the style gates diff --git a/agent-image/moon.yml b/agent-image/moon.yml index 8b8b4e4f..25d536fc 100644 --- a/agent-image/moon.yml +++ b/agent-image/moon.yml @@ -20,6 +20,7 @@ # build resolves already build through nix in this same gate. layer: 'application' language: 'nix' +tags: ['ci-group.nix'] workspace: inheritedTasks: diff --git a/app-bundle/moon.yml b/app-bundle/moon.yml index 479c97c9..06b4ef6e 100644 --- a/app-bundle/moon.yml +++ b/app-bundle/moon.yml @@ -12,6 +12,7 @@ # input changes, unconditional on main + nightly. layer: 'application' language: 'nix' +tags: ['ci-group.nix'] workspace: inheritedTasks: diff --git a/apps/eng-docs/moon.yml b/apps/eng-docs/moon.yml index 212b3c85..6a4bd035 100644 --- a/apps/eng-docs/moon.yml +++ b/apps/eng-docs/moon.yml @@ -9,7 +9,7 @@ # Access gate, no PostHog analytics. layer: 'application' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] tasks: # Mirror the monorepo's reviewed markdown into the gitignored diff --git a/apps/ui/moon.yml b/apps/ui/moon.yml index 9fdf8e17..0ab63e08 100644 --- a/apps/ui/moon.yml +++ b/apps/ui/moon.yml @@ -4,7 +4,7 @@ # (@compass/client -> compass.v1); compass-proto:drift gates its freshness. layer: 'application' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] dependsOn: ['compass-client'] tasks: diff --git a/bun.lock b/bun.lock index 418086f4..ca24c1f2 100644 --- a/bun.lock +++ b/bun.lock @@ -118,6 +118,16 @@ "typescript": "catalog:", }, }, + "tools/ci-matrix": { + "name": "@compass/ci-matrix", + "bin": { + "ci-matrix": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/cx-token-gate": { "name": "@compass/cx-token-gate", "bin": { @@ -400,6 +410,8 @@ "@compass/agent-image-env-gate": ["@compass/agent-image-env-gate@workspace:tools/agent-image-env-gate"], + "@compass/ci-matrix": ["@compass/ci-matrix@workspace:tools/ci-matrix"], + "@compass/client": ["@compass/client@workspace:packages/compass-client"], "@compass/cx-token-gate": ["@compass/cx-token-gate@workspace:tools/cx-token-gate"], diff --git a/docs/designs/infra/ci/compass-ci-job-decomposition/design.md b/docs/designs/infra/ci/compass-ci-job-decomposition/design.md index 1b2baf4f..45bdc2c7 100644 --- a/docs/designs/infra/ci/compass-ci-job-decomposition/design.md +++ b/docs/designs/infra/ci/compass-ci-job-decomposition/design.md @@ -82,20 +82,22 @@ check-name attribution plus special-infra alignment: ### Concern groups — declared by an explicit `ci-group` tag, never by project id in ci.yml -Each project declares exactly one `ci-group:` tag in its `moon.yml`, so +Each project declares exactly one `ci-group.` tag in its `moon.yml`, so group membership is data on the project itself — colocated with the project registration the vendored-fork incident showed can otherwise drift — never a -filter expression in the workflow. Membership as of `main@fc835ca6` (live +filter expression in the workflow. moon 2.5.3 tag ids forbid a colon (only +alnum / `-` / `_` / `/` / `.` are allowed), so the group delimiter is a DOT, +not the colon this record first drafted. Membership as of `main@fc835ca6` (live `moon query projects` this session): | Group | Tag (declared per project) | Members today | | --- | --- | --- | -| `go` | `ci-group:go` | `compass-go`, `compass-proto` | -| `bun` | `ci-group:bun` | 13 first-party projects (plus `root`; see below, for 14 total): `compass-agent`, `compass-client`, `compass-ui`, `compass-eng-docs`, the gate tools (`toolchain-parity`, `stamp-gate`, `design-ledger-gate`, `orion-ref-gate`, `cx-token-gate`, `agent-image-env-gate`), `renovate`, `renovate-preflight`, `forge-linear-token` | -| `nix` | `ci-group:nix` | `compass-agent-image`, `compass-guest-image`, `compass-app-bundle` — the heavy nix builds (agent-image/moon.yml:45, guest-image/moon.yml:46, app-bundle/moon.yml:32) | -| `forks` | `ci-group:forks` | **Transient** (see below) — `oh-my-pi-fork` (workspace.yml:117; TypeScript but deliberately not `bun`-tagged — its ci is upstream's own check, forks/oh-my-pi/moon.yml:52–92) | +| `go` | `ci-group.go` | `compass-go`, `compass-proto` | +| `bun` | `ci-group.bun` | 13 first-party projects (plus `root`; see below, for 14 total): `compass-agent`, `compass-client`, `compass-ui`, `compass-eng-docs`, the gate tools (`toolchain-parity`, `stamp-gate`, `design-ledger-gate`, `orion-ref-gate`, `cx-token-gate`, `agent-image-env-gate`), `renovate`, `renovate-preflight`, `forge-linear-token` | +| `nix` | `ci-group.nix` | `compass-agent-image`, `compass-guest-image`, `compass-app-bundle` — the heavy nix builds (agent-image/moon.yml:45, guest-image/moon.yml:46, app-bundle/moon.yml:32) | +| `forks` | `ci-group.forks` | **Transient** (see below) — `oh-my-pi-fork` (workspace.yml:117; TypeScript but deliberately not `bun`-tagged — its ci is upstream's own check, forks/oh-my-pi/moon.yml:52–92) | -Plus `root` (workspace.yml:16, the lint/format sweeps): tagged `ci-group:bun` +Plus `root` (workspace.yml:16, the lint/format sweeps): tagged `ci-group.bun` (it owns the single bun install the bun projects' `install` no-op depends on, workspace.yml:11–15). @@ -106,7 +108,7 @@ cost" the ci.yml header names (ci.yml:26–27) and the worst case the `gates` job's `timeout-minutes: 90` was sized for (ci.yml:118–120). The frozen forks reversal (`docs/designs/platform/compass-forks-reversal/design.md`) removes all three vendored forks; when `oh-my-pi-fork` is deleted, its -`ci-group:forks` tag vanishes with its `moon.yml`, and the group empties +`ci-group.forks` tag vanishes with its `moon.yml`, and the group empties cleanly — no untagged project is left behind, so the zero-untagged and coverage assertions still pass over the remaining groups — while the dominant-cost worst case departs with it. Nothing in this design is premised @@ -443,7 +445,7 @@ tasks T1–T4, which land value even before T5. ## Global Constraints - **No project id may appear in ci.yml.** Grouping is declared as exactly one - explicit `ci-group:` tag per project in its moon.yml; membership is + explicit `ci-group.` tag per project in its moon.yml; membership is computed at run time. The setup generator MUST fail loud — naming the offending project id in one line — on any coverage gap, double membership, or untagged project. @@ -579,9 +581,9 @@ and Retrospect. ### T5 — the moon-owned concern matrix; dissolve `gates`; gate every leg -Tag every project: add exactly one `ci-group:` tag to each project's +Tag every project: add exactly one `ci-group.` tag to each project's moon.yml (go/bun/nix/forks per the membership table above; `root` gets -`ci-group:bun`). +`ci-group.bun`). Add a `setup` job (fetch-depth-0 checkout, nix install, phase-one bootstrap, `edited` guard) running the generator — a bun-run TypeScript diff --git a/forks/oh-my-pi/moon.yml b/forks/oh-my-pi/moon.yml index 78a5d364..53d849d9 100644 --- a/forks/oh-my-pi/moon.yml +++ b/forks/oh-my-pi/moon.yml @@ -34,7 +34,7 @@ # scope decision, not an import detail. layer: 'application' language: 'typescript' -tags: ['oss'] +tags: ['oss', 'ci-group.forks'] workspace: inheritedTasks: diff --git a/go/moon.yml b/go/moon.yml index c626cd22..9e621443 100644 --- a/go/moon.yml +++ b/go/moon.yml @@ -13,7 +13,7 @@ # on PATH. layer: 'library' language: 'go' -tags: ['oss'] +tags: ['oss', 'ci-group.go'] workspace: inheritedTasks: diff --git a/guest-image/moon.yml b/guest-image/moon.yml index 25f8cbf9..b7393df9 100644 --- a/guest-image/moon.yml +++ b/guest-image/moon.yml @@ -19,6 +19,7 @@ # realises. layer: 'application' language: 'nix' +tags: ['ci-group.nix'] workspace: inheritedTasks: diff --git a/moon.yml b/moon.yml index abea7bf4..e37388d7 100644 --- a/moon.yml +++ b/moon.yml @@ -16,6 +16,8 @@ layer: 'library' language: 'typescript' +tags: ['ci-group.bun'] + workspace: inheritedTasks: exclude: ['install', 'lint', 'format'] diff --git a/packages/compass-agent/moon.yml b/packages/compass-agent/moon.yml index d6389ce3..84a115ba 100644 --- a/packages/compass-agent/moon.yml +++ b/packages/compass-agent/moon.yml @@ -8,7 +8,7 @@ # mapping is the package's own testable surface (no Runner-side translator). layer: 'library' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] tasks: typecheck: diff --git a/packages/compass-client/moon.yml b/packages/compass-client/moon.yml index 4c6cd5cc..238b0e62 100644 --- a/packages/compass-client/moon.yml +++ b/packages/compass-client/moon.yml @@ -4,7 +4,7 @@ # generated client (src/gen); compass-proto:drift gates its freshness. layer: 'library' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] dependsOn: ['compass-proto'] tasks: diff --git a/proto/moon.yml b/proto/moon.yml index 7a352b64..6a484d05 100644 --- a/proto/moon.yml +++ b/proto/moon.yml @@ -14,7 +14,7 @@ # from. layer: 'library' language: 'bash' -tags: ['oss'] +tags: ['oss', 'ci-group.go'] workspace: inheritedTasks: diff --git a/tools/agent-image-env-gate/moon.yml b/tools/agent-image-env-gate/moon.yml index 6e9ff040..2d2426a4 100644 --- a/tools/agent-image-env-gate/moon.yml +++ b/tools/agent-image-env-gate/moon.yml @@ -21,7 +21,7 @@ # `moon run :ci`), so the `ci` aggregate below is swept automatically. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/ci-matrix/biome.json b/tools/ci-matrix/biome.json new file mode 100644 index 00000000..99b4ab8f --- /dev/null +++ b/tools/ci-matrix/biome.json @@ -0,0 +1,3 @@ +{ + "extends": "//" +} diff --git a/tools/ci-matrix/index.test.ts b/tools/ci-matrix/index.test.ts new file mode 100644 index 00000000..cb2e4b22 --- /dev/null +++ b/tools/ci-matrix/index.test.ts @@ -0,0 +1,373 @@ +// Unit tests for the ci-matrix pure core (index.ts). +// +// These defend the generator's contract (.t5-contract.md § The generator): +// coverage, disjointness-throws-naming-id, zero-untagged-throws-naming-id, the +// placeholder anchor for unaffected groups, determinism under shuffled input, +// all four affected-flag rules, a null-ciTarget member being skipped from +// targets, and an empty affected closure still yielding a non-empty matrix. +// +// Only the PURE core is exercised — the edge (moon query / $GITHUB_OUTPUT) is +// import.meta.main-guarded, so importing index.ts never runs it. + +import { describe, expect, test } from "bun:test"; +import { type GenInput, generate, type ProjectInput } from "./index.ts"; + +/** A grouped project with a `ci` task target derived from its id. */ +function proj(id: string, group: string): ProjectInput { + return { id, tags: [`ci-group.${group}`], ciTarget: `${id}:ci` }; +} + +/** The canonical four-group workspace used by most tests. */ +function workspace(): ProjectInput[] { + return [ + proj("compass-go", "go"), + proj("compass-proto", "go"), + proj("compass-agent", "bun"), + proj("ci-matrix", "bun"), + proj("compass-guest-image", "nix"), + proj("oh-my-pi-fork", "forks"), + ]; +} + +/** A PR-shaped input over the canonical workspace. */ +function prInput( + over: Partial & { affectedIds: string[] }, +): GenInput { + return { + projects: workspace(), + changedPaths: [], + event: "pull_request", + ...over, + }; +} + +describe("coverage — an affected member lands in exactly its group entry", () => { + test("an affected go member fills the go entry's targets, others stay placeholders", () => { + const out = generate(prInput({ affectedIds: ["compass-go"] })); + const go = out.matrix.find((e) => e.group === "go"); + expect(go).toEqual({ + group: "go", + run: "true", + targets: ["compass-go:ci"], + }); + // Every other group is a placeholder. + for (const other of ["bun", "forks", "nix"]) { + expect(out.matrix.find((e) => e.group === other)).toEqual({ + group: other, + run: "false", + targets: [], + }); + } + }); + + test("two affected members of the same group both appear, sorted", () => { + const out = generate( + prInput({ affectedIds: ["compass-proto", "compass-go"] }), + ); + expect(out.matrix.find((e) => e.group === "go")?.targets).toEqual([ + "compass-go:ci", + "compass-proto:ci", + ]); + }); + + test("affected members spread across groups each land in their own entry", () => { + const out = generate( + prInput({ + affectedIds: ["compass-go", "compass-agent", "oh-my-pi-fork"], + }), + ); + const byGroup = new Map(out.matrix.map((e) => [e.group, e])); + expect(byGroup.get("go")?.run).toBe("true"); + expect(byGroup.get("bun")?.run).toBe("true"); + expect(byGroup.get("forks")?.run).toBe("true"); + expect(byGroup.get("nix")?.run).toBe("false"); + expect(byGroup.get("bun")?.targets).toEqual(["compass-agent:ci"]); + }); +}); + +describe("disjointness — a project with two ci-group.* tags throws, naming it", () => { + test("throws an Error whose message names the offending id", () => { + const projects = workspace(); + projects.push({ + id: "double-tagged", + tags: ["ci-group.go", "ci-group.bun"], + ciTarget: "double-tagged:ci", + }); + expect(() => + generate({ + projects, + affectedIds: [], + changedPaths: [], + event: "pull_request", + }), + ).toThrow(/double-tagged/); + }); + + test("the message includes both offending tags", () => { + const projects: ProjectInput[] = [ + { + id: "double-tagged", + tags: ["ci-group.bun", "ci-group.go"], + ciTarget: null, + }, + ]; + expect(() => + generate({ + projects, + affectedIds: [], + changedPaths: [], + event: "pull_request", + }), + ).toThrow(/ci-group\.bun.*ci-group\.go|ci-group\.go.*ci-group\.bun/); + }); +}); + +describe("zero-untagged — a project with no ci-group.* tag throws, naming it", () => { + test("throws an Error whose message names the offending id", () => { + const projects = workspace(); + projects.push({ id: "untagged", tags: ["bun"], ciTarget: "untagged:ci" }); + expect(() => + generate({ + projects, + affectedIds: [], + changedPaths: [], + event: "pull_request", + }), + ).toThrow(/untagged/); + }); + + test("a project with an empty tags list throws too", () => { + const projects: ProjectInput[] = [{ id: "bare", tags: [], ciTarget: null }]; + expect(() => + generate({ + projects, + affectedIds: [], + changedPaths: [], + event: "pull_request", + }), + ).toThrow(/bare/); + }); +}); + +describe("placeholder anchor — one entry per existing group, unaffected are placeholders", () => { + test("every existing group is present even with no affected members", () => { + const out = generate(prInput({ affectedIds: [] })); + expect(out.matrix.map((e) => e.group)).toEqual([ + "bun", + "forks", + "go", + "nix", + ]); + for (const e of out.matrix) { + expect(e.run).toBe("false"); + expect(e.targets).toEqual([]); + } + }); + + test("a group that appears on no project is absent from the matrix", () => { + // No 'forks' project here → no forks entry. + const projects = [proj("compass-go", "go"), proj("compass-agent", "bun")]; + const out = generate({ + projects, + affectedIds: ["compass-go"], + changedPaths: [], + event: "pull_request", + }); + expect(out.matrix.map((e) => e.group)).toEqual(["bun", "go"]); + }); +}); + +describe("determinism — shuffled input yields identical sorted output", () => { + test("matrix sorted by group; targets sorted; byte-identical output", () => { + const ordered = generate( + prInput({ + affectedIds: ["compass-go", "compass-proto", "compass-agent"], + }), + ); + + const shuffledProjects = [...workspace()].reverse(); + const shuffled = generate({ + projects: shuffledProjects, + affectedIds: ["compass-agent", "compass-proto", "compass-go"], + changedPaths: [], + event: "pull_request", + }); + + expect(JSON.stringify(shuffled)).toBe(JSON.stringify(ordered)); + expect(ordered.matrix.map((e) => e.group)).toEqual([ + "bun", + "forks", + "go", + "nix", + ]); + expect(ordered.matrix.find((e) => e.group === "go")?.targets).toEqual([ + "compass-go:ci", + "compass-proto:ci", + ]); + }); +}); + +describe("flags — pgtest / microvm / forge / gtk3 rules", () => { + test("pgtestAffected iff compass-go in closure", () => { + expect( + generate(prInput({ affectedIds: ["compass-go"] })).pgtestAffected, + ).toBe(true); + expect( + generate(prInput({ affectedIds: ["compass-agent"] })).pgtestAffected, + ).toBe(false); + }); + + test("microvmAffected iff compass-go OR compass-guest-image in closure", () => { + expect( + generate(prInput({ affectedIds: ["compass-go"] })).microvmAffected, + ).toBe(true); + expect( + generate(prInput({ affectedIds: ["compass-guest-image"] })) + .microvmAffected, + ).toBe(true); + expect( + generate(prInput({ affectedIds: ["compass-agent"] })).microvmAffected, + ).toBe(false); + }); + + test("forgeAffected on go/internal/forge/ path match", () => { + expect( + generate( + prInput({ + affectedIds: [], + changedPaths: ["go/internal/forge/oracle.go"], + }), + ).forgeAffected, + ).toBe(true); + }); + + test("forgeAffected on the ci.yml self-edit path", () => { + expect( + generate( + prInput({ + affectedIds: [], + changedPaths: [".github/workflows/ci.yml"], + }), + ).forgeAffected, + ).toBe(true); + // A different workflow file must NOT trigger forge. + expect( + generate( + prInput({ + affectedIds: [], + changedPaths: [".github/workflows/release.yml"], + }), + ).forgeAffected, + ).toBe(false); + }); + + test("forgeAffected false when no path matches on a PR", () => { + expect( + generate(prInput({ affectedIds: [], changedPaths: ["docs/readme.md"] })) + .forgeAffected, + ).toBe(false); + }); + + test("gtk3Affected on any go/cmd/compass-app/ path", () => { + expect( + generate( + prInput({ + affectedIds: [], + changedPaths: ["go/cmd/compass-app/main.go"], + }), + ).gtk3Affected, + ).toBe(true); + expect( + generate( + prInput({ affectedIds: [], changedPaths: ["go/cmd/other/main.go"] }), + ).gtk3Affected, + ).toBe(false); + }); + + test("push: forge + gtk3 unconditionally true, changedPaths ignored", () => { + const out = generate({ + projects: workspace(), + affectedIds: workspace().map((p) => p.id), + changedPaths: [], + event: "push", + }); + expect(out.forgeAffected).toBe(true); + expect(out.gtk3Affected).toBe(true); + }); + + test("schedule: forge + gtk3 unconditionally true", () => { + const out = generate({ + projects: workspace(), + affectedIds: workspace().map((p) => p.id), + changedPaths: [], + event: "schedule", + }); + expect(out.forgeAffected).toBe(true); + expect(out.gtk3Affected).toBe(true); + }); + + test("push: every non-empty group runs (full sweep)", () => { + const out = generate({ + projects: workspace(), + affectedIds: workspace().map((p) => p.id), + changedPaths: [], + event: "push", + }); + for (const e of out.matrix) { + expect(e.run).toBe("true"); + } + expect(out.pgtestAffected).toBe(true); + expect(out.microvmAffected).toBe(true); + }); +}); + +describe("null ciTarget member does not make its group run", () => { + test("a group whose only affected member has no ci task emits run:false", () => { + const projects: ProjectInput[] = [ + { id: "compass-go", tags: ["ci-group.go"], ciTarget: "compass-go:ci" }, + { id: "no-ci", tags: ["ci-group.go"], ciTarget: null }, + ]; + const out = generate({ + projects, + affectedIds: ["no-ci"], + changedPaths: [], + event: "pull_request", + }); + const go = out.matrix.find((e) => e.group === "go"); + // The only affected member has no ci task → no runnable target, so the + // leg must not run (a running leg always has >=1 target; a bare + // `moon run` with no target is the bug this guards against). + expect(go?.run).toBe("false"); + expect(go?.targets).toEqual([]); + }); + + test("a mixed group emits only the non-null ciTargets of affected members", () => { + const projects: ProjectInput[] = [ + { id: "compass-go", tags: ["ci-group.go"], ciTarget: "compass-go:ci" }, + { id: "no-ci", tags: ["ci-group.go"], ciTarget: null }, + ]; + const out = generate({ + projects, + affectedIds: ["compass-go", "no-ci"], + changedPaths: [], + event: "pull_request", + }); + expect(out.matrix.find((e) => e.group === "go")?.targets).toEqual([ + "compass-go:ci", + ]); + }); +}); + +describe("empty affected closure — matrix still non-empty (fromJSON safe)", () => { + test("a docs-only PR touching no grouped project yields all placeholders", () => { + const out = generate( + prInput({ affectedIds: [], changedPaths: ["docs/x.md"] }), + ); + expect(out.matrix.length).toBeGreaterThan(0); + expect(out.matrix.every((e) => e.run === "false")).toBe(true); + expect(out.pgtestAffected).toBe(false); + expect(out.microvmAffected).toBe(false); + expect(out.forgeAffected).toBe(false); + expect(out.gtk3Affected).toBe(false); + }); +}); diff --git a/tools/ci-matrix/index.ts b/tools/ci-matrix/index.ts new file mode 100755 index 00000000..0ca794d8 --- /dev/null +++ b/tools/ci-matrix/index.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env bun +// ci-matrix (T5) — the CI concern-matrix generator. +// +// PURE CORE: `generate(input)` translates moon's affected closure + every +// project's `ci-group.*` tag into the per-group GitHub Actions matrix and the +// four special-leg affected flags. It is a pure function: no I/O, no moon +// invocation, no clock, no `process`/`env`/`Bun` access. On a structural +// violation (a project with no `ci-group.*` tag, or two) it throws an Error +// whose message names the offending project id. +// +// THE EDGE: `main()` (guarded by `import.meta.main`) runs `moon query +// projects`, computes changed paths via `git diff`, calls the pure core, and +// writes `matrix=` + the four `*_affected=` lines to `$GITHUB_OUTPUT`. +// Guarding behind `import.meta.main` lets the test import the pure core without +// firing the edge. + +import { $ } from "bun"; + +// ── Pure-core types ──────────────────────────────────────────────────────── + +/** The input the pure core receives (parsed from `moon query projects` JSON). */ +export type ProjectInput = { + id: string; + /** project's config.tags (includes the ci-group.* tag) */ + tags: string[]; + /** ":ci" if the project defines a `ci` task, else null */ + ciTarget: string | null; +}; + +export type GenInput = { + /** ALL workspace projects (for the group universe + assertions) */ + projects: ProjectInput[]; + /** closure members (PR) or all ids (push/schedule) */ + affectedIds: string[]; + /** PR changed paths (for forge/gtk3 detection); [] on push/schedule */ + changedPaths: string[]; + event: "pull_request" | "push" | "schedule"; +}; + +export type MatrixEntry = { + /** "go" | "bun" | "nix" | "forks" (the tag suffix) */ + group: string; + /** 'true' iff >=1 affected member in this group (always 'true' on push/schedule) */ + run: "true" | "false"; + /** affected members' ciTarget (sorted); [] when run==='false' */ + targets: string[]; +}; + +export type GenOutput = { + /** ONE entry per group EXISTING in the workspace, sorted by group name */ + matrix: MatrixEntry[]; + /** closure contains compass-go */ + pgtestAffected: boolean; + /** closure contains compass-go OR compass-guest-image */ + microvmAffected: boolean; + /** push/schedule OR changedPaths matches forge trigger */ + forgeAffected: boolean; + /** push/schedule OR changedPaths has any path under go/cmd/compass-app/ */ + gtk3Affected: boolean; +}; + +// ── Pure-core constants ──────────────────────────────────────────────────── + +/** The prefix stripped to get the group name: tag is `ci-group.`. */ +const CI_GROUP_PREFIX = "ci-group."; + +/** Special-leg project ids the flags key off. */ +const PGTEST_PROJECT = "compass-go"; +const GUEST_IMAGE_PROJECT = "compass-guest-image"; + +/** forge trigger: changed path under go/internal/forge/ OR ci.yml itself. */ +const FORGE_PATH_RE = + /^(?:go\/internal\/forge\/|\.github\/workflows\/ci\.yml$)/; +/** gtk3 trigger: any changed path under go/cmd/compass-app/. */ +const GTK3_PATH_PREFIX = "go/cmd/compass-app/"; + +// ── Pure core ────────────────────────────────────────────────────────────── + +/** + * Translate the affected closure + tags into the concern matrix + flags. + * Pure; throws an Error naming the offending project id on assertion failure. + */ +export function generate(input: GenInput): GenOutput { + const isFullSweep = input.event === "push" || input.event === "schedule"; + + // The group of each project, keyed by id. Enforces zero-untagged + + // disjointness while building it (each throws, naming the offending id). + const groupOf = new Map(); + // The group universe: every group appearing as a ci-group.* tag on any + // project. Placeholder anchor — the matrix has one entry per existing group. + const groupUniverse = new Set(); + + for (const project of input.projects) { + const groupTags = project.tags.filter((t) => t.startsWith(CI_GROUP_PREFIX)); + if (groupTags.length === 0) { + throw new Error( + `ci-matrix: project '${project.id}' carries no ci-group.* tag`, + ); + } + if (groupTags.length > 1) { + throw new Error( + `ci-matrix: project '${project.id}' carries multiple ci-group.* tags: ${groupTags + .slice() + .sort() + .join(", ")}`, + ); + } + // biome-ignore lint/style/noNonNullAssertion: length checked === 1 above. + const group = groupTags[0]!.slice(CI_GROUP_PREFIX.length); + groupOf.set(project.id, group); + groupUniverse.add(group); + } + + // Index projects by id for ciTarget lookup during target collection. + const projectById = new Map(); + for (const project of input.projects) { + projectById.set(project.id, project); + } + + const affectedSet = new Set(input.affectedIds); + + // Collect, per group, the ci targets of affected members that have a + // non-null ciTarget. A null-ciTarget member is skipped (never emit a bare + // `:ci` for a project without the task). + const targetsByGroup = new Map(); + for (const group of groupUniverse) { + targetsByGroup.set(group, []); + } + for (const id of input.affectedIds) { + const group = groupOf.get(id); + // coverage: an affected id with no group means it is not in `projects`. + // The group universe is built from all projects, so this can only happen + // if the caller passed an affectedId absent from `projects`. + if (group === undefined) { + throw new Error( + `ci-matrix: affected project '${id}' has no ci-group.* mapping (not in projects list)`, + ); + } + const project = projectById.get(id); + const ciTarget = project?.ciTarget ?? null; + if (ciTarget !== null) { + // biome-ignore lint/style/noNonNullAssertion: group is in groupUniverse. + targetsByGroup.get(group)!.push(ciTarget); + } + } + + // Build the matrix: one entry per existing group, sorted by group name. + // group == tag-suffix and groupUniverse is a Set, so group names are unique + // by construction — the emitted check names cannot collide. + const matrix: MatrixEntry[] = []; + for (const group of [...groupUniverse].sort()) { + // biome-ignore lint/style/noNonNullAssertion: group is in groupUniverse. + const targets = targetsByGroup.get(group)!.slice().sort(); + // A group runs iff it has >=1 runnable target. On a full sweep every id + // is affected, so every group with a ci task runs; on a PR only groups + // with an affected, ci-task-bearing member run. Deriving `run` from + // `targets` (not mere membership) upholds the ci.yml invariant that a + // running leg always has >=1 target, so `moon run` is never targetless: + // a group whose only affected member has no ci task emits run:'false'. + const run: "true" | "false" = targets.length > 0 ? "true" : "false"; + matrix.push({ + group, + run, + targets, + }); + } + + const pgtestAffected = affectedSet.has(PGTEST_PROJECT); + const microvmAffected = + affectedSet.has(PGTEST_PROJECT) || affectedSet.has(GUEST_IMAGE_PROJECT); + const forgeAffected = + isFullSweep || input.changedPaths.some((p) => FORGE_PATH_RE.test(p)); + const gtk3Affected = + isFullSweep || + input.changedPaths.some((p) => p.startsWith(GTK3_PATH_PREFIX)); + + return { + matrix, + pgtestAffected, + microvmAffected, + forgeAffected, + gtk3Affected, + }; +} + +// ── The edge (impure) ────────────────────────────────────────────────────── + +/** The subset of `moon query projects` JSON the edge reads. */ +type MoonProject = { + id: string; + config?: { tags?: string[] }; + taskTargets?: string[]; +}; + +/** Parse a `moon query projects` payload into ProjectInput[]. */ +function parseProjects(json: string): ProjectInput[] { + const parsed = JSON.parse(json) as { projects?: MoonProject[] }; + const projects = parsed.projects ?? []; + return projects.map((p) => { + const tags = p.config?.tags ?? []; + const ciTarget = + (p.taskTargets ?? []).find((t) => t.endsWith(":ci")) ?? null; + return { id: p.id, tags, ciTarget }; + }); +} + +async function main(): Promise { + const eventNameRaw = process.env.GITHUB_EVENT_NAME ?? "push"; + const event: GenInput["event"] = + eventNameRaw === "pull_request" + ? "pull_request" + : eventNameRaw === "schedule" + ? "schedule" + : "push"; + + // Full set: group universe + tags + ci-task presence. + const fullJson = await $`moon query projects`.quiet().text(); + const projects = parseProjects(fullJson); + + // Affected set: the affected closure on a PR, else the full set. + let affectedIds: string[]; + if (event === "pull_request") { + const affectedJson = + await $`moon query projects --affected --upstream deep --downstream direct` + .quiet() + .text(); + const affectedParsed = JSON.parse(affectedJson) as { + projects?: MoonProject[]; + }; + affectedIds = (affectedParsed.projects ?? []).map((p) => p.id); + } else { + affectedIds = projects.map((p) => p.id); + } + + // Changed paths for forge/gtk3 detection (PR only; unused on push/schedule + // where the flags are unconditionally true). + let changedPaths: string[] = []; + if (event === "pull_request") { + const baseRef = process.env.GITHUB_BASE_REF ?? ""; + if (baseRef !== "") { + const diff = await $`git diff --name-only origin/${baseRef}...HEAD` + .nothrow() + .quiet() + .text(); + changedPaths = diff + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); + } + } + + try { + const out = generate({ projects, affectedIds, changedPaths, event }); + + const lines = [ + `matrix=${JSON.stringify(out.matrix)}`, + `pgtest_affected=${out.pgtestAffected ? "true" : "false"}`, + `microvm_affected=${out.microvmAffected ? "true" : "false"}`, + `forge_affected=${out.forgeAffected ? "true" : "false"}`, + `gtk3_affected=${out.gtk3Affected ? "true" : "false"}`, + ]; + + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput != null && githubOutput !== "") { + const existing = (await Bun.file(githubOutput).exists()) + ? await Bun.file(githubOutput).text() + : ""; + await Bun.write( + Bun.file(githubOutput), + `${existing}${lines.join("\n")}\n`, + ); + } + + // Human summary. + console.log(`ci-matrix (${event}):`); + for (const entry of out.matrix) { + console.log( + ` [${entry.run === "true" ? "run " : "skip"}] ${entry.group}: ${ + entry.targets.length > 0 ? entry.targets.join(" ") : "(none)" + }`, + ); + } + console.log( + ` flags: pgtest=${out.pgtestAffected} microvm=${out.microvmAffected} forge=${out.forgeAffected} gtk3=${out.gtk3Affected}`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`::error::${message}`); + process.exit(1); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/tools/ci-matrix/moon.yml b/tools/ci-matrix/moon.yml new file mode 100644 index 00000000..309622c7 --- /dev/null +++ b/tools/ci-matrix/moon.yml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# ci-matrix (T5) — the CI concern-matrix generator. Reads moon's affected +# closure and every project's `ci-group.*` tag, then emits (to $GITHUB_OUTPUT) +# the per-group GitHub Actions matrix + the four special-leg affected flags +# (pgtest/microvm/forge/gtk3). A bun/TypeScript CLI; a hoisted root-workspace +# member (`bun` tag): install is inherited via .moon/tasks/tag-bun.yml (the +# shared root install), so this leaf has no own bun.lock and never runs its own +# install. It is itself a `ci-group.bun` project so the generator's own +# zero-untagged assertion does not fire on it. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + test: + command: 'bun test' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/ci-matrix/package.json b/tools/ci-matrix/package.json new file mode 100644 index 00000000..2b628ffd --- /dev/null +++ b/tools/ci-matrix/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/ci-matrix", + "private": true, + "type": "module", + "description": "CI concern-matrix generator: reads moon's affected closure + ci-group.* tags and emits the per-group GitHub Actions matrix + the special-leg affected flags.", + "module": "index.ts", + "bin": { + "ci-matrix": "./index.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/ci-matrix/tsconfig.json b/tools/ci-matrix/tsconfig.json new file mode 100644 index 00000000..47d3248b --- /dev/null +++ b/tools/ci-matrix/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "types": ["bun"] + } +} diff --git a/tools/cx-token-gate/moon.yml b/tools/cx-token-gate/moon.yml index 22770742..7a17b03a 100644 --- a/tools/cx-token-gate/moon.yml +++ b/tools/cx-token-gate/moon.yml @@ -17,7 +17,7 @@ # `moon run :ci`), so the `ci` aggregate below is swept automatically. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/design-ledger-gate/moon.yml b/tools/design-ledger-gate/moon.yml index 18df7982..ba2bc56c 100644 --- a/tools/design-ledger-gate/moon.yml +++ b/tools/design-ledger-gate/moon.yml @@ -15,7 +15,7 @@ # wiring (unlike sealed's Woodpecker pipeline). layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/forge-linear-token/moon.yml b/tools/forge-linear-token/moon.yml index e5105b93..3d0f5012 100644 --- a/tools/forge-linear-token/moon.yml +++ b/tools/forge-linear-token/moon.yml @@ -20,7 +20,7 @@ # here. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/microvm-boot-test/moon.yml b/tools/microvm-boot-test/moon.yml index 22664d97..978d057a 100644 --- a/tools/microvm-boot-test/moon.yml +++ b/tools/microvm-boot-test/moon.yml @@ -17,7 +17,7 @@ # tasks, so this leaf carries no own bun.lock. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/orion-ref-gate/moon.yml b/tools/orion-ref-gate/moon.yml index bd7eb9af..3c499b1a 100644 --- a/tools/orion-ref-gate/moon.yml +++ b/tools/orion-ref-gate/moon.yml @@ -11,7 +11,7 @@ # `moon run :ci`), so the `ci` aggregate below is swept automatically. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/renovate-preflight/moon.yml b/tools/renovate-preflight/moon.yml index 75879489..d0535948 100644 --- a/tools/renovate-preflight/moon.yml +++ b/tools/renovate-preflight/moon.yml @@ -12,7 +12,7 @@ # inherited via the `bun` tag (.moon/tasks/tag-bun.yml). layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: @@ -24,3 +24,8 @@ tasks: command: 'bun test' deps: ['install'] inputs: ['*.ts', 'package.json', 'tsconfig.json', '/bun.lock'] + + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/renovate/moon.yml b/tools/renovate/moon.yml index 0d99f217..13369ef8 100644 --- a/tools/renovate/moon.yml +++ b/tools/renovate/moon.yml @@ -14,7 +14,7 @@ # fence/cap rule goes missing, or if the catalog regex silently truncates. layer: 'tool' language: 'typescript' -tags: ['bun'] +tags: ['bun', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/stamp-gate/moon.yml b/tools/stamp-gate/moon.yml index f3b0284b..381e77fe 100644 --- a/tools/stamp-gate/moon.yml +++ b/tools/stamp-gate/moon.yml @@ -11,7 +11,7 @@ # branch including the two fail-closed ones a shell version would drop. layer: 'tool' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] tasks: typecheck: diff --git a/tools/toolchain/moon.yml b/tools/toolchain/moon.yml index 4f007438..6523a547 100644 --- a/tools/toolchain/moon.yml +++ b/tools/toolchain/moon.yml @@ -12,7 +12,7 @@ # before pushing rather than from a red run. layer: 'tool' language: 'typescript' -tags: ['bun', 'oss'] +tags: ['bun', 'oss', 'ci-group.bun'] tasks: typecheck: