diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0ae3ecbf..bbce24034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ # 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, gtk4-e2e — remain -# their own peer jobs. A single required `CI` rollup (below) still fronts the +# their own peer jobs. A single required `rollup` check (below) still fronts the # whole set, so branch protection requires one check regardless of the fan-out. # # AFFECTED ON PRs, FULL ON MAIN — now per leg. A pull request runs only what it @@ -46,28 +46,30 @@ # reaches main. # # 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 +# the `rollup` check, 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. +# them. It joins the `rollup` check's `needs`, so there is still one required check. -name: CI +name: ci on: pull_request: - # The default types (opened, synchronize, reopened) OMIT `edited`, which is - # the only event fired when a PR's BASE branch is re-pointed. A stacked PR - # whose base flips when the PR below it merges/closes fires only - # `pull_request.edited` — with the default set, no fresh CI runs against the - # new base and `gh run rerun` only replays the frozen stale merge SHA (a - # phantom red against the dead base's tree; the only escape was close+reopen). - # `edited` closes that gap. It also fires on title/body edits, so the job - # below guards on `changes.base` to stay a no-op for everything but a real - # base re-point — no added CI spend on a title/body edit. - types: [opened, synchronize, reopened, edited] + # The default types (opened, synchronize, reopened). `edited` is + # deliberately ABSENT: jj-vine edits every PR's body ~1s after opening it, + # and an `edited` trigger fired a full phantom run that self-skipped and + # double-listed every check on the PR (a cosmetic artifact with a latent + # skipped-required-as-passing false green). The one thing `edited` used to + # buy — a fresh run when a stacked PR's BASE is re-pointed — is handled + # out of band by pr-base-repoint.yml, which guards on `changes.base` and + # re-triggers this workflow via `workflow_dispatch` (see the `pr` input + # below). A triggered run cannot post zero contexts (a skipped job still + # posts a `skipped` context), so the phantom could not be narrowed away in + # place — the trigger itself had to move. + types: [opened, synchronize, reopened] push: branches: [main] schedule: @@ -76,31 +78,45 @@ on: # 07:17 UTC, off the top-of-hour cron congestion GitHub throttles. - cron: '17 7 * * *' workflow_dispatch: - # The forge fixture-regeneration lane (regen-forge-fixtures job below). It is - # operator-triggered ONLY — the live `-update` capture rewrites the golden - # testdata against the real testbed, whose PATs live only in Actions secrets, - # so a local `-update` is impossible. The gate events above never trigger the - # regen job (its own `if:` gates on this event name); this trigger exists - # solely so an operator can dispatch a fixture refresh from the Actions tab. + # TWO distinct dispatch lanes, disambiguated by the `pr` input: + # - `pr` EMPTY → the operator-only forge fixture-regeneration lane + # (regen-forge-fixtures below). The live `-update` capture rewrites the + # golden testdata against the real testbed, whose PATs live only in + # Actions secrets, so a local `-update` is impossible. Its own `if:` + # gates on `workflow_dispatch` AND an empty `pr`, so the gate jobs stay + # off it and this trigger only refreshes fixtures from the Actions tab. + # - `pr` SET (a PR number) → the base-re-point re-trigger. When a stacked + # PR's base is re-pointed, pr-base-repoint.yml dispatches this workflow + # with `pr=`; the gate jobs below run the FULL battery (as on a + # push) against that PR's freshly-recomputed `refs/pull//merge` — a + # default `GITHUB_TOKEN` event triggers no workflow, so + # `workflow_dispatch` is the one re-trigger that needs no minted + # credential. A fresh run (unlike `gh run rerun`) picks up the merge + # ref GitHub recomputes against the new base (actions/checkout#919). + inputs: + pr: + description: 'PR number to re-run against its recomputed merge ref (base re-point); empty = forge fixture regen' + required: false + default: '' # One in-flight run per ref per event action. A superseded push's run is # answering a question nobody is asking any more. Both the event and its action # are in the key so runs answering different questions about the same ref don't # cancel each other: a nightly schedule and a coincident post-merge push -# (different events), and a PR's `opened`/`edited`/`synchronize` sub-actions -# (same event, different questions). That last case is load-bearing: jj-vine -# opens a PR, then ~1s later edits the body to add the stack diagram, and that -# `edited` run must not cancel the still-running real `opened` run — otherwise -# the required check ends cancelled+skipped (the edited run self-skips via the -# job `if` below) and reads as a false green. +# (different events). A PR branch's superseded pushes fire +# `pull_request.synchronize`, so they share one group and collapse to one +# in-flight run per ref, as intended. # # `push` and `schedule` payloads have no `action` field, so # `${{ github.event.action }}` renders to a constant empty string for them — -# their grouping is unchanged by this key addition (every main push still -# collapses to `ci--push-` and still cancels the prior one; a schedule run -# still coexists with a coincident push via the distinct event name). A PR -# branch's superseded pushes fire `pull_request.synchronize`, so they too share -# one group and collapse to one in-flight run per ref, as intended. +# their grouping is unchanged by this key (every main push still collapses to +# `ci--push-` and still cancels the prior one; a schedule run still +# coexists with a coincident push via the distinct event name). A base-re-point +# re-trigger arrives as `workflow_dispatch` on the PR's HEAD branch, so its +# `github.ref` (`refs/heads/`) differs from the PR's own +# `pull_request` runs (`refs/pull//merge`) — no cross-cancel between a +# re-point run and a concurrent push to that branch — while two re-points of +# the same PR share a group and the newer supersedes the older. concurrency: group: ci-${{ github.ref }}-${{ github.event_name }}-${{ github.event.action }} cancel-in-progress: true @@ -110,19 +126,21 @@ permissions: jobs: setup: - name: Setup (concern matrix) + name: setup 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. + # untagged / double-tagged project or coverage gap. Same dispatch-aware gate + # guard every gate job carries. + # Run on every gate event (push, schedule, pull_request) and on a + # base-re-point re-trigger — a `workflow_dispatch` carrying a non-empty + # `pr` input. Stay OFF the fixture-regen dispatch (empty `pr`), which + # runs regen-forge-fixtures alone. `github.event.inputs.pr` is '' for + # every non-dispatch event, so the left disjunct carries those. if: >- - github.event_name != 'workflow_dispatch' && - (github.event_name != 'pull_request' || - github.event.action != 'edited' || - github.event.changes.base != null) + github.event_name != 'workflow_dispatch' || + github.event.inputs.pr != '' timeout-minutes: 15 env: # Same short temp root as every gate job — see the moon battery below for @@ -148,6 +166,12 @@ jobs: # detection diffs base...HEAD — both need real history, not a shallow # single-ref checkout. fetch-depth: 0 + # On a base-re-point re-trigger (workflow_dispatch with pr set) check + # out the PR's merge ref, which GitHub recomputes against the new base + # for a FRESH run (actions/checkout#919 — a plain rerun would replay + # the stale merge SHA). Empty on every other event → checkout's default + # triggering ref. + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: @@ -209,7 +233,7 @@ jobs: run: bun run tools/ci-matrix/index.ts moon: - name: Moon battery (${{ matrix.group }}) + name: moon (${{ matrix.group }}) runs-on: ubuntu-latest needs: setup # One leg per concern group, discovered at run time from setup's matrix. No @@ -217,19 +241,18 @@ jobs: # 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. + # its check and, through the rollup, the required `rollup` 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). + # Same dispatch-aware gate guard every gate job carries (see setup): run on + # push/schedule/pull_request and on a base-re-point re-trigger + # (workflow_dispatch with a non-empty pr); stay off the fixture-regen + # dispatch (empty pr, regen-only). if: >- - github.event_name != 'workflow_dispatch' && - (github.event_name != 'pull_request' || - github.event.action != 'edited' || - github.event.changes.base != null) + github.event_name != 'workflow_dispatch' || + github.event.inputs.pr != '' # 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 @@ -259,6 +282,9 @@ jobs: # The drift gate's `git diff --no-index` needs real git; a shallow # single-ref checkout does not provide it. fetch-depth: 0 + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - if: matrix.run == 'true' uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 @@ -379,22 +405,20 @@ jobs: uses: appthrust/moon-ci-retrospect@631a1e667f96e8ca893eab69f03e425f7fbfdc2b # v2.1.0 pgtest: - name: pgtest (real-Postgres suites) + name: pgtest 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. The job-level `if:` - # ANDs the shared no-op-`edited` guard with the pgtest affected gate: on a PR + # ANDs the dispatch-aware gate 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_name != 'workflow_dispatch' || + github.event.inputs.pr != '') && (github.event_name != 'pull_request' || needs.setup.outputs.pgtest_affected == 'true') timeout-minutes: 20 @@ -428,6 +452,10 @@ jobs: # checkout: pgtest runs NO git diff (its guard greps files via `grep -rl`), # so a default shallow checkout suffices — no `fetch-depth: 0` needed. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: @@ -551,22 +579,20 @@ jobs: exit "$rc" microvm: - name: microvm (KVM boot suite) + name: microvm 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. The job-level `if:` ANDs the shared no-op-`edited` guard with the + # rollup. The job-level `if:` ANDs the dispatch-aware gate 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_name != 'workflow_dispatch' || + github.event.inputs.pr != '') && (github.event_name != 'pull_request' || needs.setup.outputs.microvm_affected == 'true') timeout-minutes: 30 @@ -578,6 +604,10 @@ jobs: steps: # microVM runs no git-diff, so a default shallow checkout suffices. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: @@ -777,22 +807,21 @@ jobs: exit "$rc" forge-oracle: - name: forge-oracle (live-contract oracle) + name: forge 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. 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 + # own lane behind the rollup; the tri-event + same-repo-head guards stay + # on the steps as defense-in-depth. The job-level `if:` ANDs the + # dispatch-aware gate 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 + # and on a base-re-point re-trigger. 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_name != 'workflow_dispatch' || + github.event.inputs.pr != '') && (github.event_name != 'pull_request' || needs.setup.outputs.forge_affected == 'true') timeout-minutes: 30 @@ -803,6 +832,9 @@ jobs: with: # The forge-affected git diff needs the base branch history. fetch-depth: 0 + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: @@ -880,7 +912,7 @@ jobs: # Fail LOUD if the base ref does not resolve rather than swallowing the # error as "not affected" (mirrors the gtk4-affected gate): an # unresolvable base (e.g. a stacked PR's base churning under a - # pull_request.edited re-point) must red the gate, never skip it green. + # base re-point) must red the gate, never skip it green. if ! git rev-parse --verify --quiet "$base" >/dev/null; then echo "::error::forge affected gate: base ref $base does not resolve — cannot compute the affected set" exit 1 @@ -915,7 +947,9 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && steps.forge_affected.outputs.forge_affected == 'true') || github.event_name == 'push' || - github.event_name == 'schedule' + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.pr != '') env: LINEAR_FORGE_CLIENT_ID: ${{ secrets.LINEAR_FORGE_CLIENT_ID }} LINEAR_FORGE_CLIENT_SECRET: ${{ secrets.LINEAR_FORGE_CLIENT_SECRET }} @@ -949,7 +983,9 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && steps.forge_affected.outputs.forge_affected == 'true') || github.event_name == 'push' || - github.event_name == 'schedule' + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.pr != '') env: # The T2 env contract (design §358-362, §431-436): the GitHub live legs # gate on the LIVEGITHUB_* trio, the Linear live legs gate independently @@ -1006,7 +1042,9 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && steps.forge_affected.outputs.forge_affected == 'true') || github.event_name == 'push' || - github.event_name == 'schedule' + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.pr != '') run: | src=internal/forge/livegithub_test.go ghskip=$(sed -n 's/.*liveSkipMessage = "\(.*\)"/\1/p' "$src") @@ -1032,21 +1070,20 @@ jobs: echo "forge oracle: the live-contract suite ran both the GitHub and Linear legs against the testbed and reported ok" gtk4-e2e: - name: gtk4-e2e (multi-window native app gate) + name: gtk4 runs-on: ubuntu-latest needs: setup - # Peeled out of the moon gate into its own lane behind the CI rollup; the + # Peeled out of the moon gate into its own lane behind the rollup; the # native GTK4/WebKit app gate realizes a heavy out-of-band WebKitGTK/weston # closure the bare moon gate has no business building. The job-level `if:` - # ANDs the shared no-op-`edited` guard with the gtk4 affected gate: on a PR + # ANDs the dispatch-aware gate guard with the gtk4 affected gate: on a PR # it runs only when setup found go/cmd/compass-app/ or a shared GTK closure - # input changed (gtk4_affected), and always on push/schedule. The in-step - # affected guard stays as defense-in-depth. No privileged container. + # input changed (gtk4_affected), and always on push/schedule and on a + # base-re-point re-trigger. 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_name != 'workflow_dispatch' || + github.event.inputs.pr != '') && (github.event_name != 'pull_request' || needs.setup.outputs.gtk4_affected == 'true') timeout-minutes: 30 @@ -1057,6 +1094,9 @@ jobs: with: # The gtk4 affected `git -C ..` diff needs the base branch history. fetch-depth: 0 + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31 with: @@ -1134,7 +1174,8 @@ jobs: # the closure is the only other input that changes what it links), so a # Go/UI/docs PR never pays the WebKitGTK realization. push-to-main and the # nightly schedule always run it (the full-sweep backstop), matching the - # moon affected/full split. + # moon affected/full split; a base-re-point re-trigger (workflow_dispatch) + # also runs it full. if: success() working-directory: go env: @@ -1147,7 +1188,7 @@ jobs: # fetch-depth:0 (job checkout) gives the base ref for the diff. Fail # LOUD if it does not resolve rather than swallowing the error as # "not affected": an unresolvable base (e.g. a stacked PR's base - # churning under a pull_request.edited re-point) must red the gate, + # churning under a base re-point) must red the gate, # never skip it green. No `|| true` on the diff for the same reason — # under `bash -e` a genuine diff failure aborts the step. if ! git -C .. rev-parse --verify --quiet "$base" >/dev/null; then @@ -1334,8 +1375,8 @@ jobs: exit 1 fi - dogfood-e2e: - name: Dogfood e2e (deterministic full-stack tier) + e2e: + name: e2e runs-on: ubuntu-latest # 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 @@ -1356,7 +1397,7 @@ jobs: # services by label) and break the Real-Postgres step's DSN. This tier needs # no Postgres service anyway — it stands up its OWN private postgres # (go/cmd/compass-postgres) inside the stack. The cost is a second nix + - # toolchain bootstrap; the `CI` rollup below keeps the single required check. + # toolchain bootstrap; the `rollup` check below keeps the single required check. container: # A glibc≥2.27 image carrying podman + newuidmap: the runner mounts its # node20 (dynamically linked) into the container to run the JS actions @@ -1370,13 +1411,13 @@ jobs: # container sandbox withholds. image: quay.io/podman/stable@sha256:663e0dbf407987b7db3f20d3588c283a8228db17b282d2029a482d4d47e36964 options: --privileged - # 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). + # Mirror the moon battery's dispatch-aware gate guard: run on + # push/schedule/pull_request and on a base-re-point re-trigger + # (workflow_dispatch with a non-empty pr); stay off the fixture-regen + # dispatch (empty pr, regen-only). if: >- - github.event_name != 'workflow_dispatch' && - (github.event_name != 'pull_request' || - github.event.action != 'edited' || - github.event.changes.base != null) + github.event_name != 'workflow_dispatch' || + github.event.inputs.pr != '' timeout-minutes: 60 env: # Same short temp root as the moon battery: the e2e suite binds AF_UNIX sockets @@ -1524,6 +1565,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # Base-re-point re-trigger checks out the recomputed merge ref; empty + # on every other event (see setup's checkout). + ref: ${{ github.event.inputs.pr != '' && format('refs/pull/{0}/merge', github.event.inputs.pr) || '' }} - name: Hand the checkout to the podman user (as root) # actions/checkout runs as the container user (root), so the worktree @@ -1699,8 +1743,22 @@ jobs: # image_affected=false — silently pulling published :latest and testing # the WRONG image with no signal. A broken moon means the detection # cannot be trusted, so fail the step loudly rather than guess. - if: github.event_name == 'pull_request' + # On a base-re-point re-trigger (workflow_dispatch with pr) this runs + # too, but not the moon query: the merge ref is already checked out and + # `:latest` is still stale relative to a re-pointed PR's own image + # changes, so building from the merge tree is the only choice that can + # never test a stale image — set image_affected=true unconditionally + # (the re-point is rare, so always paying the build is the fail-safe + # trade). moon's affected query has no base ref on a dispatch anyway. + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.pr != '') run: | + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + echo "image_affected=true" >>"$GITHUB_OUTPUT" + exit 0 + fi if ! moon_out=$(moon query tasks --affected --project compass-agent-image); then echo "::error::moon query for compass-agent-image affected-detection failed; cannot trust the image-affected signal, so failing rather than testing a possibly-stale published image" exit 1 @@ -1767,7 +1825,7 @@ jobs: podman tag ghcr.io/rigelbuild/compass-agent:latest compass-agent:latest fi - - name: Dogfood e2e (deterministic full-stack tier) + - name: e2e (deterministic full-stack tier) working-directory: go env: # -race needs cgo, matching go/moon.yml's race lane. Without it `go @@ -1835,8 +1893,8 @@ jobs: fi echo "dogfood e2e: the deterministic full-stack tier ran and reported ok" - CI: - name: CI + rollup: + name: rollup # The single required check: a rollup that succeeds only if every work job # above did. Requiring THIS one status (not each work job) means the set of # jobs can be split or renamed without touching branch protection — a new @@ -1852,10 +1910,10 @@ 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.moon.result == 'cancelled'`, and red the required `CI` + # see `needs.moon.result == 'cancelled'`, and red the required `rollup` # 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 + # rollup on a cancelled run, so a superseded commit's `rollup` check is # neutral, never red; the ref's tip run is never superseded, so it always # gets a real verdict and the merge gate is unaffected. A per-job cancel # that is NOT a run supersede (a work-job `timeout-minutes`) does not set @@ -1863,36 +1921,31 @@ jobs: # on that `cancelled` result below. The one deliberate narrowing vs # `always()`: manually cancelling the TIP run (a human clicking Cancel on # the PR head's own run, not a supersede) also lands in the cancelled - # state, so the rollup skips and the required `CI` check goes neutral — + # state, so the rollup skips and the required `rollup` check goes neutral — # i.e. mergeable without a verdict. That takes a deliberate human action # against one's own gating run and is the accepted, standard `!cancelled()` # tradeoff; `always()` would have red'd it instead. - # - `github.event_name != 'workflow_dispatch'`: the work jobs stay off a - # fixture-regen dispatch (regen-forge-fixtures runs alone there), so both - # `needs` skip — this rollup must skip too, or `!cancelled()` would red the - # required check on `skipped != success` for a run that never gates a merge. - # - the SAME no-op-`edited` guard the work jobs carry, so on a PR title/body - # edit that did not move the base — where both work jobs skip — this rollup - # skips too, rather than running and failing on `skipped != success`. A - # bare `!cancelled()` would red the required check on a legitimate no-op - # edit. - # `!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: [setup, moon, dogfood-e2e, pgtest, microvm, forge-oracle, gtk4-e2e] + # - the work jobs stay off a fixture-regen dispatch (regen-forge-fixtures + # runs alone there, with an empty `pr` input), so both `needs` skip — + # this rollup must skip too, or `!cancelled()` would red the required + # check on `skipped != success` for a run that never gates a merge. It + # DOES run on a base-re-point re-trigger (workflow_dispatch with a + # non-empty `pr`), where the work jobs run and it must render a verdict. + # `!cancelled()` must be parenthesized against the dispatch OR group: `&&` + # binds tighter than `||`, so without the parens the trailing + # `|| inputs.pr != ''` would detach from the AND. + needs: [setup, moon, e2e, pgtest, microvm, forge-oracle, gtk4-e2e] if: >- !cancelled() && - github.event_name != 'workflow_dispatch' && ( - github.event_name != 'pull_request' || - github.event.action != 'edited' || - github.event.changes.base != null ) + (github.event_name != 'workflow_dispatch' || + github.event.inputs.pr != '') runs-on: ubuntu-latest steps: - name: Require every work job to have succeeded # `needs..result` is one of success | failure | cancelled | skipped. # 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. + # cancelled RUN skips the whole rollup, and a fixture-regen dispatch + # 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 @@ -1913,7 +1966,7 @@ jobs: run: | setup='${{ needs.setup.result }}' moon='${{ needs.moon.result }}' - dogfood='${{ needs.dogfood-e2e.result }}' + e2e='${{ needs.e2e.result }}' pgtest='${{ needs.pgtest.result }}' microvm='${{ needs.microvm.result }}' forge='${{ needs.forge-oracle.result }}' @@ -1922,7 +1975,7 @@ jobs: microvm_flag='${{ needs.setup.outputs.microvm_affected }}' forge_flag='${{ needs.setup.outputs.forge_affected }}' gtk4_flag='${{ needs.setup.outputs.gtk4_affected }}' - echo "setup=$setup moon=$moon dogfood-e2e=$dogfood pgtest=$pgtest microvm=$microvm forge-oracle=$forge gtk4-e2e=$gtk4" + echo "setup=$setup moon=$moon e2e=$e2e pgtest=$pgtest microvm=$microvm forge-oracle=$forge gtk4-e2e=$gtk4" echo "flags: pgtest=$pgtest_flag microvm=$microvm_flag forge=$forge_flag gtk4=$gtk4_flag" rc=0 # setup and moon must plainly succeed. @@ -1934,9 +1987,9 @@ jobs: 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)" + # e2e is not affected-gated — plain success required. + if [ "$e2e" != "success" ]; then + echo "::error::e2e did not succeed (result=$e2e)" rc=1 fi # Each gated special leg: success always passes; skipped passes ONLY @@ -1963,16 +2016,20 @@ jobs: echo "all required work jobs succeeded (or correctly skipped per their setup flag)" regen-forge-fixtures: - name: Regenerate forge golden fixtures + name: regen-fixtures runs-on: ubuntu-latest # The operator-only fixture-regeneration lane. The live `-update` capture # (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 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' + # gating on `workflow_dispatch` with an EMPTY `pr` input keeps it off every + # normal gate event AND off the base-re-point re-trigger (which carries a + # non-empty `pr` and drives the gate jobs, not this regen lane), so the + # required `rollup` check above (which does not `needs:` this job) is untouched. + if: >- + github.event_name == 'workflow_dispatch' && + github.event.inputs.pr == '' # The top-level `permissions: contents: read` is the floor; this job widens # its own to open a PR: contents:write to push the regen branch, # pull-requests:write to open the PR against it. diff --git a/.github/workflows/eng-docs-deploy.yml b/.github/workflows/eng-docs-deploy.yml index 819fea7da..6f5876067 100644 --- a/.github/workflows/eng-docs-deploy.yml +++ b/.github/workflows/eng-docs-deploy.yml @@ -5,7 +5,7 @@ # deploy.ts, SEA-1765). This is a STANDALONE CD workflow — it is deliberately # NOT part of `moon ci :ci` (the CI gate in ci.yml), so a deploy failure never # gates a merge and a green CI never waits on Cloudflare. -name: eng-docs-deploy +name: eng-docs on: push: @@ -22,6 +22,7 @@ concurrency: jobs: deploy: + name: deploy runs-on: ubuntu-latest # Fork guard: a push always runs; a PR only runs when it comes from a branch # in THIS repo. Fork PRs get no secrets (CLOUDFLARE_*/GH_TOKEN), so a deploy diff --git a/.github/workflows/pr-base-repoint.yml b/.github/workflows/pr-base-repoint.yml new file mode 100644 index 000000000..5e882c3d4 --- /dev/null +++ b/.github/workflows/pr-base-repoint.yml @@ -0,0 +1,121 @@ +# Base-re-point re-trigger for stacked PRs. +# +# ci.yml deliberately does NOT listen to `pull_request.edited`: that event +# fires on every title/body edit (jj-vine edits every PR's body ~1s after +# opening it), and the phantom run it produced self-skipped and double-listed +# every check on the PR — a cosmetic mess with a latent +# skipped-required-as-passing false green. But `edited` is ALSO the only event +# fired when a stacked PR's BASE branch is re-pointed (the PR below it +# merges/closes and GitHub re-points this PR at the new base). That case needs +# a fresh CI run against the recomputed merge commit; a plain `gh run rerun` +# only replays the frozen stale merge SHA (actions/checkout#919), which is the +# exact phantom red `edited` was once added to fix. +# +# So `edited` lives HERE, narrowly: this workflow guards on `changes.base` so +# it is a no-op for every title/body edit, and on a real base re-point it +# re-triggers ci.yml out of band. The re-trigger is a `workflow_dispatch` +# carrying the PR number, NOT a label bounce or a close/reopen: an event +# created with the default `GITHUB_TOKEN` triggers no new workflow run (the +# sole exceptions are `workflow_dispatch` and `repository_dispatch`), so +# `workflow_dispatch` is the one re-trigger that needs no minted App token. +# ci.yml's dispatch arm checks out `refs/pull//merge`, which GitHub +# recomputes against the new base for a fresh run, and runs the full battery +# against it. + +name: pr-base-repoint + +on: + pull_request: + types: [edited] + +# Least privilege: the one thing this workflow does is dispatch ci.yml, which +# needs `actions: write`. Nothing else. +permissions: + actions: write + +# One re-trigger in flight per PR. A second base re-point supersedes the first; +# the older dispatch is answering a question about a base that no longer holds. +concurrency: + group: pr-base-repoint-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + repoint: + name: guard + runs-on: ubuntu-latest + # Fire ONLY on a real base re-point, and ONLY for a same-repo PR. `edited` + # sets `github.event.changes.base` only when the base actually moved, so a + # title/body edit is `null` and skips here — the no-op the phantom-removal + # depends on. The fork guard is a hard boundary: a fork PR's token is + # read-only (it could not dispatch anyway) and must never be handed + # `actions: write`; a fork's own CI already runs from its fork, and a fork + # base re-point is vanishingly rare, so skipping it costs nothing. + if: >- + github.event.changes.base != null && + github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 5 + steps: + - name: Re-trigger ci.yml against the recomputed merge ref + id: dispatch + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + # Dispatch ci.yml with `pr=`; ci.yml's gate jobs recognise a + # non-empty `pr` as a base-re-point re-trigger (an empty `pr` is the + # separate operator-only fixture-regen lane) and check out + # `refs/pull//merge`. `--ref` is the PR's HEAD branch: a dispatch + # needs a ref that carries the workflow, and the head branch is where a + # stacked PR lives; ci.yml's concurrency key keys the dispatch run to + # that head ref, distinct from the PR's own `pull_request` runs. + # + # Record a UTC timestamp taken JUST BEFORE the dispatch. The verify + # step below anchors its poll to runs created after this instant, so it + # confirms THIS dispatch's run — never a pre-existing queued run on the + # same head branch (a rapid double re-point), which would otherwise let + # the verify pass even if this dispatch silently produced nothing. + run: | + echo "since=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$GITHUB_OUTPUT" + gh workflow run ci.yml --ref "$HEAD_REF" -f pr="$PR_NUMBER" + + - name: Verify a fresh ci.yml run started + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SINCE: ${{ steps.dispatch.outputs.since }} + # "Loud to a log" is not loud to the gate: if the dispatch silently + # produced no run, the PR would keep showing its green stale checks and + # nobody would know the re-point went untested. So poll the runs API for + # a `workflow_dispatch` ci.yml run on this head branch created at or + # after the dispatch instant, and fail this guard RED if none appears + # within the window. The guard being red is itself the signal the + # operator sees on the PR. + run: | + # Match a workflow_dispatch ci.yml run on our head branch whose + # createdAt is >= the instant we dispatched (SINCE), so a pre-existing + # run from an earlier re-point can never satisfy this check — only a + # run this step actually caused. Poll up to ~2min for run-creation lag. + deadline=$(( SECONDS + 120 )) + found=0 + while [ "$SECONDS" -lt "$deadline" ]; do + n=$(gh run list \ + --workflow ci.yml \ + --branch "$HEAD_REF" \ + --event workflow_dispatch \ + --limit 10 \ + --json status,createdAt \ + --jq '[.[] | select(.createdAt >= env.SINCE)] | length') + if [ "${n:-0}" -gt 0 ]; then + found=1 + break + fi + sleep 10 + done + if [ "$found" != 1 ]; then + echo "::error::dispatched ci.yml for PR #$PR_NUMBER on $HEAD_REF but no fresh workflow_dispatch run created at/after $SINCE appeared within the window — the base re-point was NOT re-tested; investigate before trusting this PR's checks" + exit 1 + fi + echo "fresh ci.yml workflow_dispatch run confirmed for $HEAD_REF (created >= $SINCE)" diff --git a/.github/workflows/publish-agent-image.yml b/.github/workflows/publish-agent-image.yml index d29a400b0..794a4a056 100644 --- a/.github/workflows/publish-agent-image.yml +++ b/.github/workflows/publish-agent-image.yml @@ -39,7 +39,7 @@ # the pull side. See docs/architecture/build-and-ci.md and the design record # docs/designs/infra/ci/compass-agent-image-publish/design.md. -name: Publish agent image +name: agent-image on: push: @@ -79,7 +79,7 @@ concurrency: jobs: publish: - name: Publish + name: publish runs-on: ubuntu-latest # workflow_dispatch runs on any branch; guard so a dispatch from a feature # branch can never mint a `:git-` for unmerged code nor move `:latest` diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index f8fae21ca..dde8d2c00 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -11,7 +11,7 @@ # secret surface. The App's Workflows permission is LOAD-BEARING — the # github-actions manager pushes commits under .github/workflows/, which a GitHub # App may only do with that permission (see the T8 human-action runbook). -name: Renovate +name: renovate on: schedule: @@ -40,6 +40,7 @@ concurrency: jobs: renovate: + name: run runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.moon/workspace.yml b/.moon/workspace.yml index d0de3f608..8060e696a 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -63,11 +63,6 @@ projects: # The toolchain version-parity gate: asserts CI's PATH holds the dev shell's # toolchain, and carries the unit tests for its own comparison logic. toolchain-parity: 'tools/toolchain' - # The devenv-CLI source tool: resolves the devenv CLI source (flakeref or a - # single-binary PATH shim) from a named devenv.lock, so renovate.yml and - # ci.yml share one lock-tracking resolver instead of hand-pinning a rev - # (RIG-2546). Carries its own unit tests for the pure resolution half. - devenv-cli: 'tools/toolchain/devenv-cli' # The generator-stamp gate: asserts the checked-in gen trees' `@generated by` # headers agree with each other and with the nixpkgs protoc-gen-es on PATH # (SEA-1405). Separate from compass-proto because its subject is the plugin diff --git a/app-bundle/SMOKE.md b/app-bundle/SMOKE.md index 210c250b0..c32bf9af0 100644 --- a/app-bundle/SMOKE.md +++ b/app-bundle/SMOKE.md @@ -31,7 +31,7 @@ board → one agent session to a real container — end to end. ## What the automated gates cover -- **`dogfood-e2e`** stands up a real headless stack (`compass-stack` / +- **`ci / e2e`** stands up a real headless stack (`compass-stack` / `compass-postgres` / a podman-run agent container) and drives it end to end on every PR. That is the stack-side regression check; a green PR has already proven the headless bring-up. @@ -168,7 +168,7 @@ rm -rf "$STATE" "$RT" ## Manual checklist What a human confirms on the dev box (the headless bring-up itself is the -`dogfood-e2e` gate's job — not on this list): +`ci / e2e` gate's job — not on this list): - [ ] client `app.toml` is client-only (`mode = "client"`, https `server_url`, `ca_cert` = the stack's `tls.crt`); no token in it (§2) diff --git a/go/internal/stack/postgres_image.go b/go/internal/stack/postgres_image.go index bbde69ced..c2d3a4f9f 100644 --- a/go/internal/stack/postgres_image.go +++ b/go/internal/stack/postgres_image.go @@ -17,14 +17,10 @@ package stack // major/minor-version-sensitive, and a mutable tag would ship an unreviewed // database under the installed stack. // -// Bump procedure: a DIGEST bump within postgres:18 (an upstream security rebuild, -// same major) is automated — Renovate surfaces this const as a docker dep via a -// customManager in tools/renovate/config.json5 (depName `postgres-stack`) and -// opens a reviewable PR to advance the digest. A MAJOR bump (18 -> 19) is frozen -// by DL-260 for on-disk-format stability and stays manual: the customManager's -// packageRule pins allowedVersions to /^18$/, so 18 -> 19 is never auto-proposed. -// When you do move the major deliberately, re-run the T8 container integration -// test (up -> probe DSN -> fresh-process down -> container gone) against the new -// digest before landing, and keep the major aligned with pgtest.go's pin -// discipline so a dev-box stack and an installed stack never skew on-disk format. +// Bump procedure: advance the digest below when the postgres minor/major moves, +// then re-run the T8 container integration test (up -> probe DSN -> fresh-process +// down -> container gone) against the new digest before landing. Keep the major +// aligned with pgtest.go's pin discipline so a dev-box stack and an installed +// stack never skew on-disk format. This is a Go const Renovate cannot see (like +// pgtest.go's pgImage), so it moves only via a reviewed manual PR. const DefaultPostgresImage = "docker.io/library/postgres:18@sha256:1957b2ff3137e4ef7f3bc813e74fff50b1e1ffddc85c8b9d6f14ade972be8687" diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5 index e2e8a5f2d..b59ac0bd0 100644 --- a/tools/renovate/config.json5 +++ b/tools/renovate/config.json5 @@ -251,37 +251,6 @@ depNameTemplate: "renovate", datasourceTemplate: "npm", }, - { - // ── Containerized postgres image (RIG-2774, DL-260) ── - // go/internal/stack/postgres_image.go pins the S4 container-backed postgres - // image (DefaultPostgresImage) as a Go const the native managers can't see. - // Surface it as a docker dep so upstream postgres:18 security REBUILDS (same - // major, new digest) flow through a reviewable PR instead of the pin silently - // rotting. This is a STANDALONE default the T8 adapter runs, with NO parity - // coupling — unlike pgtest.go's pgImage, disabled below because it is locked - // to ci.yml's service image. DL-260 freezes the MAJOR at 18 for on-disk- - // format stability; the packageRule below (allowedVersions /^18$/) moves only - // the digest, so an 18->19 major is a deliberate design action (re-run the T8 - // podman integration test), never an auto-PR. - // - // depName is `postgres-stack`, NOT `postgres`: the CI-service fence below is - // `matchDepNames: ["postgres"], enabled: false` with no manager/file scope, - // so a `postgres` depName here would inherit that disable and open zero PRs. - // The distinct name keeps the two postgres pins independently governed. - // - // versioningTemplate is explicit `docker`: a custom.regex manager defaults to - // `semver-coerced` regardless of datasource (see the catalog manager note - // above), which mishandles a `@` docker reference. - customType: "regex", - managerFilePatterns: ["/^go/internal/stack/postgres_image\\.go$/"], - matchStrings: [ - "postgres:(?\\d+)@(?sha256:[a-f0-9]{64})", - ], - depNameTemplate: "postgres-stack", - packageNameTemplate: "docker.io/library/postgres", - datasourceTemplate: "docker", - versioningTemplate: "docker", - }, ], // ── The github-actions manager: SHA-pin maintenance (RIG-2432) ── @@ -411,23 +380,6 @@ matchDepNames: ["postgres"], enabled: false, }, - { - // ── Containerized postgres image: digest-only within major 18 (RIG-2774) ── - // DL-260 freezes the postgres MAJOR at 18 (on-disk-format stability). Pin the - // allowed version to major 18 so only the digest moves; an 18->19 major bump - // is a deliberate design action (re-run the T8 podman integration test to - // verify on-disk-format compatibility), never an auto-PR. Scoped by the - // distinct `postgres-stack` depName (see the customManager above) so it never - // touches the `postgres` CI-service fence above. No matchUpdateTypes: the - // rule must apply to ALL update types so allowedVersions filters a major - // candidate too — scoping it to `digest` would leave a `19` unfiltered. The - // repo-wide 5-day minimumReleaseAge soak is KEPT: Docker Hub carries a - // tag_last_pushed timestamp for the digest, so a rebuild clears the window - // and opens a PR (no permanent-pending stall the git-refs channel dep has). - matchManagers: ["custom.regex"], - matchDepNames: ["postgres-stack"], - allowedVersions: "/^18$/", - }, { // ── gomod `go` directive: manual floor policy ── // The `go` directive in go/go.mod tracks the tools/toolchain/versions/go.nix diff --git a/tools/renovate/config.test.ts b/tools/renovate/config.test.ts index bf67bf3ee..3561e33cf 100644 --- a/tools/renovate/config.test.ts +++ b/tools/renovate/config.test.ts @@ -662,151 +662,6 @@ describe("tools/renovate postgres + gomod go disables", () => { }); }); -describe("tools/renovate postgres-stack digest manager (RIG-2774, DL-260)", () => { - // DefaultPostgresImage (go/internal/stack/postgres_image.go) is a standalone Go - // const the native managers can't see; a custom.regex manager surfaces it as a - // docker dep so upstream postgres:18 rebuilds (same major, new digest) flow - // through a reviewable PR. DL-260 freezes the major at 18, so the paired - // packageRule pins allowedVersions to /^18$/ — the digest moves, an 18->19 - // major never auto-opens. Find both by behavior, not index. - const pgManager = cfg.customManagers?.find((m) => - m.managerFilePatterns?.some((p) => p.includes("postgres_image")), - ); - const pgRule = cfg.packageRules.find( - (r) => - r.matchManagers?.includes("custom.regex") && - r.matchDepNames?.includes("postgres-stack"), - ); - - test("a docker custom.regex manager surfaces the pin (postgres-stack, docker versioning)", () => { - expect(pgManager).toBeDefined(); - expect(pgManager?.customType).toBe("regex"); - expect(pgManager?.datasourceTemplate).toBe("docker"); - expect(pgManager?.depNameTemplate).toBe("postgres-stack"); - expect(pgManager?.packageNameTemplate).toBe("docker.io/library/postgres"); - // Explicit docker versioning: a custom.regex manager defaults to - // semver-coerced regardless of datasource, which mishandles a - // @ docker reference. - expect(pgManager?.versioningTemplate).toBe("docker"); - }); - - test("its regex extracts the tag + digest from the real postgres_image.go", () => { - const src = readFileSync( - join(repoRoot, "go", "internal", "stack", "postgres_image.go"), - "utf8", - ); - const pattern = pgManager?.matchStrings?.[0]; - expect(pattern).toBeDefined(); - // Exactly one qualifying pin: use matchAll (not exec) so a second - // accidental postgres:NN@sha256 string in the Go file — which Renovate - // would silently extract as a second dep — fails this build closed. - const matches = [...src.matchAll(new RegExp(pattern as string, "g"))]; - expect(matches).toHaveLength(1); - expect(matches[0]?.groups?.currentValue).toBe("18"); - expect(matches[0]?.groups?.currentDigest).toMatch(/^sha256:[a-f0-9]{64}$/); - }); - - test("the digest-only-within-18 rule exists (postgres-stack, allowedVersions /^18$/)", () => { - expect(pgRule).toBeDefined(); - const allowedVersions = pgRule?.allowedVersions ?? ""; - expect(allowedVersions).toBe("/^18$/"); - expect(pgRule?.matchDepNames).toEqual(["postgres-stack"]); - // No matchUpdateTypes: the version filter must apply to ALL update types so - // an 18->19 major candidate is filtered too — scoping to `digest` would - // leave a major unfiltered. - expect(pgRule?.matchUpdateTypes).toBeUndefined(); - // Semantic teeth: derive the matcher from the configured value (strip the - // /.../ delimiters) and assert it accepts 18 while rejecting a 19 major — - // so a fat-fingered allowedVersions (e.g. /^1[89]$/) that still admits 19 - // fails here, not just a changed literal. - const versionMatcher = new RegExp(allowedVersions.slice(1, -1)); - expect(versionMatcher.test("18")).toBe(true); - expect(versionMatcher.test("19")).toBe(false); - }); - - // Load-bearing behavioral guard: the CI-service disable fence - // (matchDepNames ["postgres"], enabled false) is unscoped by manager/file, so a - // `postgres` depName here would inherit the disable and open ZERO PRs. Replay - // Renovate's last-match-wins packageRule semantics (mirroring resolveGroupName's - // gates) for a synthetic postgres-stack docker dep and confirm it resolves - // ENABLED — this fails closed if the fence (or any future unscoped rule) ever - // swallows postgres-stack, silently defeating the automation. - const resolveEnabled = (dep: SyntheticDep): boolean => { - let enabled = true; - for (const rule of cfg.packageRules) { - if (rule.matchManagers && !rule.matchManagers.includes(dep.manager)) { - continue; - } - if ( - rule.matchUpdateTypes && - !(dep.updateType && rule.matchUpdateTypes.includes(dep.updateType)) - ) { - continue; - } - if ( - rule.matchDepTypes && - !(dep.depType && rule.matchDepTypes.includes(dep.depType)) - ) { - continue; - } - if ( - rule.matchDepNames && - !(dep.depName && rule.matchDepNames.includes(dep.depName)) - ) { - continue; - } - if ( - rule.matchPackageNames && - !(dep.packageName && rule.matchPackageNames.includes(dep.packageName)) - ) { - continue; - } - if ( - rule.matchFileNames && - !( - dep.fileName && - rule.matchFileNames.some((g) => - globToRegExp(g).test(dep.fileName as string), - ) - ) - ) { - continue; - } - if ( - rule.excludeDepNames && - dep.depName && - rule.excludeDepNames.includes(dep.depName) - ) { - continue; - } - if (typeof rule.enabled === "boolean") enabled = rule.enabled; - } - return enabled; - }; - - test("a postgres-stack docker dep resolves ENABLED (fence independence)", () => { - expect( - resolveEnabled({ - manager: "custom.regex", - depName: "postgres-stack", - packageName: "docker.io/library/postgres", - fileName: "go/internal/stack/postgres_image.go", - updateType: "digest", - }), - ).toBe(true); - // And the original `postgres` CI-service dep stays DISABLED — the two pins - // remain independently governed. - expect( - resolveEnabled({ - manager: "github-actions", - depName: "postgres", - fileName: ".github/workflows/ci.yml", - updateType: "digest", - }), - ).toBe(false); - }); -}); - describe("tools/renovate bun-types soak exemption ↔ bunfig excludes", () => { // The catalog-scoped soak-exemption packageRule governs ONLY catalog deps // (matchManagers custom.regex + matchDepTypes workspaces.catalog), so its diff --git a/tools/toolchain/devenv-cli/core.test.ts b/tools/toolchain/devenv-cli/core.test.ts deleted file mode 100644 index c6abcf29b..000000000 --- a/tools/toolchain/devenv-cli/core.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -// Tests for the pure half of the devenv-CLI source tool (RIG-2546 §T1). -// -// The properties under test: the tool is SOURCE-AGNOSTIC (it reads whatever -// owner/repo/rev the named lock names — cachix upstream or the RigelBuild fork), -// it FAILS LOUD on any shape drift (missing node, short rev, non-github type) -// rather than resolving a stale/wrong source, and its dependency-free -// convention is a CHECKED property, not a comment. The bin-dir shim's -// single-binary invariant (RD-3) is unit-checked via the pure shimPlan helper. - -import { describe, expect, test } from "bun:test"; -import { devenvSource, flakeref, parseArgs, shimPlan } from "./core.ts"; - -// A cachix-shaped node (the root lock today) — WITH a `dir: src/modules` field, -// which the tool must ignore. -const CACHIX_LOCK = JSON.stringify({ - nodes: { - devenv: { - locked: { - dir: "src/modules", - owner: "cachix", - repo: "devenv", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - type: "github", - }, - }, - }, -}); - -// A RigelBuild-shaped node (the agent-image lock) — no `dir` field. -const RIGELBUILD_LOCK = JSON.stringify({ - nodes: { - devenv: { - locked: { - owner: "RigelBuild", - repo: "devenv", - rev: "15a81f3e15619187fcbe10c2eac40878e0b4ce28", - type: "github", - }, - }, - }, -}); - -describe("devenvSource", () => { - test("parses a cachix-shaped node, ignoring the dir field", () => { - expect(devenvSource(CACHIX_LOCK)).toEqual({ - owner: "cachix", - repo: "devenv", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - }); - }); - - test("parses a RigelBuild-shaped node — the tool is source-agnostic", () => { - expect(devenvSource(RIGELBUILD_LOCK)).toEqual({ - owner: "RigelBuild", - repo: "devenv", - rev: "15a81f3e15619187fcbe10c2eac40878e0b4ce28", - }); - }); - - test("throws when the devenv node is absent", () => { - const lock = JSON.stringify({ nodes: { root: { locked: {} } } }); - expect(() => devenvSource(lock)).toThrow(/nodes\.devenv\.locked absent/); - }); - - test("throws on a rev shorter than 40 hex", () => { - const lock = JSON.stringify({ - nodes: { - devenv: { - locked: { - owner: "cachix", - repo: "devenv", - rev: "0bf6765c", - type: "github", - }, - }, - }, - }); - expect(() => devenvSource(lock)).toThrow(/40-hex devenv rev/); - }); - - test("throws on a non-github node type", () => { - const lock = JSON.stringify({ - nodes: { - devenv: { - locked: { - owner: "cachix", - repo: "devenv", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - type: "git", - }, - }, - }, - }); - expect(() => devenvSource(lock)).toThrow(/expected "github"/); - }); - - test("throws when the devenv node has no owner", () => { - const lock = JSON.stringify({ - nodes: { - devenv: { - locked: { - repo: "devenv", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - type: "github", - }, - }, - }, - }); - expect(() => devenvSource(lock)).toThrow(/no owner/); - }); - - test("throws when the devenv node has no repo", () => { - const lock = JSON.stringify({ - nodes: { - devenv: { - locked: { - owner: "cachix", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - type: "github", - }, - }, - }, - }); - expect(() => devenvSource(lock)).toThrow(/no repo/); - }); - - test("throws on an owner with flakeref-reshaping characters", () => { - const lock = JSON.stringify({ - nodes: { - devenv: { - locked: { - owner: "a/b#x", - repo: "devenv", - rev: "0bf6765ce7071d98ed137ecfe02d1e435007c971", - type: "github", - }, - }, - }, - }); - expect(() => devenvSource(lock)).toThrow(/bare github owner/); - }); - - test("throws on invalid JSON", () => { - expect(() => devenvSource("not json")).toThrow(/not valid JSON/); - }); -}); - -describe("flakeref", () => { - test("composes the exact cachix flakeref", () => { - expect(flakeref(devenvSource(CACHIX_LOCK))).toBe( - "github:cachix/devenv/0bf6765ce7071d98ed137ecfe02d1e435007c971#devenv", - ); - }); - - test("composes the exact RigelBuild flakeref", () => { - expect(flakeref(devenvSource(RIGELBUILD_LOCK))).toBe( - "github:RigelBuild/devenv/15a81f3e15619187fcbe10c2eac40878e0b4ce28#devenv", - ); - }); -}); - -describe("parseArgs", () => { - test("parses --lock/--mode in order", () => { - expect(parseArgs(["--lock", "devenv.lock", "--mode", "bin-dir"])).toEqual({ - lockPath: "devenv.lock", - mode: "bin-dir", - }); - }); - - test("parses --mode/--lock in either order", () => { - expect( - parseArgs(["--mode", "flakeref", "--lock", "agent-image/devenv.lock"]), - ).toEqual({ lockPath: "agent-image/devenv.lock", mode: "flakeref" }); - }); - - test("throws on an unknown flag", () => { - expect(() => - parseArgs(["--lock", "devenv.lock", "--mode", "flakeref", "--extra"]), - ).toThrow(/unknown argument/); - }); - - test("throws when --lock is missing", () => { - expect(() => parseArgs(["--mode", "flakeref"])).toThrow( - /--lock is required/, - ); - }); - - test("throws when --mode is missing", () => { - expect(() => parseArgs(["--lock", "devenv.lock"])).toThrow( - /--mode is required/, - ); - }); - - test("throws on an invalid --mode value", () => { - expect(() => parseArgs(["--lock", "devenv.lock", "--mode", "wat"])).toThrow( - /invalid --mode/, - ); - }); - - test("throws when a flag is missing its value", () => { - expect(() => parseArgs(["--lock", "--mode", "flakeref"])).toThrow( - /--lock requires a value/, - ); - }); -}); - -describe("shimPlan (RD-3 single-binary invariant)", () => { - test("plans exactly one entry named devenv pointing at the out-path bin", () => { - const plan = shimPlan("/nix/store/abc-devenv-1.0"); - expect(plan).toEqual([ - { link: "devenv", target: "/nix/store/abc-devenv-1.0/bin/devenv" }, - ]); - // The load-bearing property: exactly one entry, named `devenv`, so the - // printed dir cannot put devenv's whole closure bin dir on $GITHUB_PATH. - expect(plan).toHaveLength(1); - expect(plan.map((l) => l.link)).toEqual(["devenv"]); - }); -}); - -describe("import hygiene (dependency-free convention as a checked property)", () => { - test("core.ts and index.ts import only node:/bun: builtins or ./core", async () => { - const root = new URL(".", import.meta.url).pathname; - const sources = await Promise.all( - ["core.ts", "index.ts"].map((f) => Bun.file(`${root}${f}`).text()), - ); - // Static `import ... from "x"`, side-effect `import "x"`, dynamic - // `import("x")` / `require("x")`, and re-export `export ... from "x"` — - // all specifier forms must resolve to a builtin or ./core, since the - // tool runs before `bun install`. - const specifierRes = [ - /import\s+(?:type\s+)?[^"']*?from\s+["']([^"']+)["']/g, - /import\s+["']([^"']+)["']/g, - /(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g, - /export\s+(?:type\s+)?(?:\*|\{[^}]*\}|[^;]*?)\s+from\s+["']([^"']+)["']/g, - ]; - const isAllowed = (specifier: string): boolean => - specifier.startsWith("node:") || - specifier.startsWith("bun:") || - specifier === "./core" || - specifier === "./core.ts"; - for (const source of sources) { - for (const importRe of specifierRes) { - for (const match of source.matchAll(importRe)) { - const specifier = match[1]; - expect( - isAllowed(specifier), - `disallowed import specifier: ${specifier}`, - ).toBe(true); - } - } - } - }); -}); diff --git a/tools/toolchain/devenv-cli/core.ts b/tools/toolchain/devenv-cli/core.ts deleted file mode 100644 index 0f6ef2c44..000000000 --- a/tools/toolchain/devenv-cli/core.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Pure resolution for the devenv-CLI source tool (RIG-2546). No I/O, no process -// exec — everything here is a total function over strings, so the load-bearing -// half (lock JSON → validated coordinates → flakeref; argv → parsed request) is -// unit-testable (core.test.ts) and the executable shell (index.ts) stays thin. -// This mirrors the tools/toolchain/parity.ts / parity-core.ts split, and the -// lock-parse posture of tools/renovate/refresh-devenv-nixpkgs.core.ts:25 — a -// shape change must fail the caller loudly, never resolve a stale/wrong source. - -/** The devenv node's locked coordinates, as a nix flakeref fragment. */ -export interface DevenvSource { - readonly owner: string; - readonly repo: string; - readonly rev: string; // 40-hex, validated -} - -/** - * Parse `.nodes.devenv.locked` out of a devenv.lock's text. Throws loudly on - * missing node, missing/short rev, or non-github type — a shape change must - * fail the caller, never resolve a stale or wrong source (the same posture as - * refresh-devenv-nixpkgs.core.ts's innerNixpkgsRev). - * - * The `dir` field some locks carry (e.g. the root lock's `src/modules`) is - * deliberately IGNORED: the `#devenv` flake attribute is what the flakeref - * selects, not a source subdir, so DevenvSource carries only owner/repo/rev. - */ -export function devenvSource(lockText: string): DevenvSource { - let lock: unknown; - try { - lock = JSON.parse(lockText); - } catch (error) { - throw new Error(`devenv-cli: devenv.lock is not valid JSON: ${error}`); - } - // Narrow with `in`/`typeof` at each level so every access is actually - // checked (devenv.lock is external-boundary data; no schema validator is in - // the repo). A shape change surfaces as a loud throw, never a silent read. - const isObj = (v: unknown): v is Record => - typeof v === "object" && v !== null; - let locked: Record | undefined; - if (isObj(lock) && "nodes" in lock && isObj(lock.nodes)) { - const node = lock.nodes.devenv; - if (isObj(node) && "locked" in node && isObj(node.locked)) { - locked = node.locked; - } - } - if (locked === undefined) { - throw new Error( - "devenv-cli: could not read the devenv node from devenv.lock " + - "(nodes.devenv.locked absent) — devenv lock shape may have changed.", - ); - } - const { type, owner, repo, rev } = locked; - if (type !== "github") { - throw new Error( - `devenv-cli: devenv node type is ${JSON.stringify(type)}, expected "github".`, - ); - } - if (typeof owner !== "string" || owner === "") { - throw new Error("devenv-cli: devenv node has no owner in devenv.lock."); - } - if (!/^[A-Za-z0-9-]+$/.test(owner)) { - throw new Error( - "devenv-cli: devenv node owner is not a bare github owner " + - "(nodes.devenv.locked.owner) — devenv lock shape may be malformed.", - ); - } - if (typeof repo !== "string" || repo === "") { - throw new Error("devenv-cli: devenv node has no repo in devenv.lock."); - } - if (!/^[A-Za-z0-9._-]+$/.test(repo)) { - throw new Error( - "devenv-cli: devenv node repo is not a bare github repo " + - "(nodes.devenv.locked.repo) — devenv lock shape may be malformed.", - ); - } - if (typeof rev !== "string" || !/^[a-f0-9]{40}$/.test(rev)) { - throw new Error( - "devenv-cli: could not read a 40-hex devenv rev from devenv.lock " + - "(nodes.devenv.locked.rev) — devenv lock shape may have changed.", - ); - } - return { owner, repo, rev }; -} - -/** `github://#devenv` for the parsed node. */ -export function flakeref(src: DevenvSource): string { - return `github:${src.owner}/${src.repo}/${src.rev}#devenv`; -} - -/** What the caller wants printed. */ -export type Mode = "flakeref" | "bin-dir"; - -export interface Request { - readonly lockPath: string; // e.g. "devenv.lock" | "agent-image/devenv.lock" - readonly mode: Mode; -} - -const MODES: readonly Mode[] = ["flakeref", "bin-dir"]; - -function isMode(value: string): value is Mode { - return (MODES as readonly string[]).includes(value); -} - -/** - * Parse argv (`--lock --mode `, either order); throws - * on an unknown flag, a missing flag value, a missing required flag, or an - * invalid mode. Fail loud rather than defaulting — a mistyped invocation must - * not silently resolve the wrong lock or mode. - */ -export function parseArgs(argv: readonly string[]): Request { - let lockPath: string | undefined; - let mode: Mode | undefined; - for (let i = 0; i < argv.length; i++) { - const flag = argv[i]; - if (flag === "--lock" || flag === "--mode") { - const value = argv[i + 1]; - if (value === undefined || value.startsWith("--")) { - throw new Error(`devenv-cli: ${flag} requires a value.`); - } - i++; - if (flag === "--lock") { - lockPath = value; - } else if (isMode(value)) { - mode = value; - } else { - throw new Error( - `devenv-cli: invalid --mode ${JSON.stringify(value)}, expected one of ${MODES.join(", ")}.`, - ); - } - continue; - } - throw new Error(`devenv-cli: unknown argument ${JSON.stringify(flag)}.`); - } - if (lockPath === undefined) { - throw new Error("devenv-cli: --lock is required."); - } - if (mode === undefined) { - throw new Error("devenv-cli: --mode is required."); - } - return { lockPath, mode }; -} - -/** One symlink to create in the bin-dir shim: `link` (a name) → `target`. */ -export interface ShimLink { - readonly link: string; - readonly target: string; -} - -/** - * The single-binary shim plan for a `nix build` out-path: exactly one symlink - * named `devenv` pointing at `/bin/devenv`. Extracted as a pure helper - * so the load-bearing RD-3 invariant — the printed dir exposes ONE binary, not - * devenv's whole closure bin dir, so appending it to $GITHUB_PATH cannot shadow - * the parity-pinned toolchain — is unit-checked without a nix build. - */ -export function shimPlan(outPath: string): readonly ShimLink[] { - return [{ link: "devenv", target: `${outPath}/bin/devenv` }]; -} diff --git a/tools/toolchain/devenv-cli/index.ts b/tools/toolchain/devenv-cli/index.ts deleted file mode 100755 index 8e92d83a9..000000000 --- a/tools/toolchain/devenv-cli/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bun -// The devenv-CLI source tool (RIG-2546): the single place that turns "the -// devenv node of a named devenv.lock" into a usable devenv CLI. Shared by -// .github/workflows/renovate.yml (mode=bin-dir → PATH) and ci.yml (mode=flakeref -// → `nix run`), so neither carries a hand-pinned rev or its own jq/nix blob. -// -// This is the thin execution shell — parse argv, read the lock, resolve, maybe -// build, print one line. All parsing and validation lives in ./core.ts, which -// is pure and unit-tested (./core.test.ts). -// -// bun tools/toolchain/devenv-cli/index.ts --lock --mode -// mode=flakeref → print `github://#devenv` (no build, no network) -// mode=bin-dir → `nix build --no-link --print-out-paths `, create a -// temp dir holding a single `devenv` symlink → its bin, print that dir -// -// stdout: exactly one line (the value); all diagnostics to stderr; exit 1 on -// any failure (bad args, missing/invalid lock, failed build). - -import { execFileSync } from "node:child_process"; -import { mkdtempSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { devenvSource, flakeref, parseArgs, shimPlan } from "./core.ts"; - -async function main(): Promise { - const request = parseArgs(Bun.argv.slice(2)); - const lockText = await Bun.file(request.lockPath).text(); - const ref = flakeref(devenvSource(lockText)); - - if (request.mode === "flakeref") { - console.log(ref); - return; - } - - // mode=bin-dir: realize the store path and expose a single `devenv` binary. - const out = execFileSync( - "nix", - ["build", "--no-link", "--print-out-paths", ref], - // stdout stays 'pipe' (we read the out-path below); nix's stderr is - // inherited so its real build diagnostic streams straight through - // instead of being swallowed into error.stderr and lost to the - // generic "Command failed" message the outer catch would print. - { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, - ).trim(); - if (out === "") { - throw new Error(`devenv-cli: nix build produced no out-path for ${ref}.`); - } - // One symlink named `devenv`, not the raw `/bin` — appending the whole - // closure bin dir to $GITHUB_PATH could shadow the parity-pinned toolchain - // (RD-3). shimPlan encodes that single-binary invariant. - // Intentionally never removed: the caller appends this dir to $GITHUB_PATH - // and needs it after this process exits (CI runners are ephemeral, so no - // unlink is wanted — cleaning it up would break the PATH contract). - const shimDir = mkdtempSync(join(tmpdir(), "devenv-shim-")); - for (const { link, target } of shimPlan(out)) { - symlinkSync(target, join(shimDir, link)); - } - console.log(shimDir); -} - -try { - await main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -} diff --git a/tools/toolchain/devenv-cli/moon.yml b/tools/toolchain/devenv-cli/moon.yml deleted file mode 100644 index 62c96acd6..000000000 --- a/tools/toolchain/devenv-cli/moon.yml +++ /dev/null @@ -1,32 +0,0 @@ -# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json -# -# The devenv-CLI source tool (RIG-2546). The single place that resolves the -# devenv CLI source from a named devenv.lock: parse `.nodes.devenv.locked` → -# `github://#devenv`, and (mode=bin-dir) realize it and expose -# a single-binary shim dir. Shared by .github/workflows/renovate.yml (PATH) and -# ci.yml (nix run) so neither carries a hand-pinned rev or its own jq/nix blob. -# -# A moon project, not a bare workflow step, for the same reason as the parity -# gate: its pure half is real logic that must itself be gated (`test`, -# `typecheck` below) — an unverified helper both workflows depend on is a -# liability. The parity task is intentionally absent; this tool is not the -# parity gate. -layer: 'tool' -language: 'typescript' -tags: ['bun', 'oss', 'ci-group.bun'] - -tasks: - typecheck: - command: 'bunx tsc --noEmit' - deps: ['install'] - inputs: ['*.ts', 'tsconfig.json', '/bun.lock'] - - test: - command: 'bun test' - deps: ['install'] - inputs: ['*.ts', '/bun.lock'] - - ci: - deps: ['typecheck', 'test'] - options: - cache: false diff --git a/tools/toolchain/devenv-cli/package.json b/tools/toolchain/devenv-cli/package.json deleted file mode 100644 index 4cceb444a..000000000 --- a/tools/toolchain/devenv-cli/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@compass/devenv-cli", - "version": "0.1.0", - "private": true, - "description": "Resolves the devenv CLI source from a named devenv.lock (parses .nodes.devenv.locked into a github://#devenv flakeref, and optionally realizes it into a single-binary shim dir). Shared by renovate.yml and ci.yml so neither hand-pins a devenv rev. Dependency-free by design — it runs in renovate.yml before `bun install` has, so it may import only bun/node builtins.", - "license": "MIT OR Apache-2.0", - "type": "module", - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/tools/toolchain/devenv-cli/tsconfig.json b/tools/toolchain/devenv-cli/tsconfig.json deleted file mode 100644 index db8363cde..000000000 --- a/tools/toolchain/devenv-cli/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "preserve", - "moduleResolution": "bundler", - "strict": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "skipLibCheck": true, - "allowImportingTsExtensions": true, - "types": ["bun"] - }, - "include": ["*.ts"] -}