From 02b2f2938a964fd5e8bb4b3ed0be011e47aae83c Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 02:29:44 +0000 Subject: [PATCH] feat(pregate): CI carry is gstack's default remote gate route; the VM is opt-in (plan choice F1) The uncleared remote_gate obligation names the CI route first when the base declares remote_gate_runs. docs/pregate.md says how gstack clears it, including the pull_request merge-ref tree mismatch. GSTA-258 piece A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta-258-a-ci-carry.md | 2 ++ docs/pregate.md | 23 ++++++++++++++++++++++- lib/pregate/checks/lanes.ts | 5 ++++- test/pregate-carry-gate.test.ts | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/changelog.d/gsta-258-a-ci-carry.md b/changelog.d/gsta-258-a-ci-carry.md index 24f7eca1c..95515a0a9 100644 --- a/changelog.d/gsta-258-a-ci-carry.md +++ b/changelog.d/gsta-258-a-ci-carry.md @@ -5,3 +5,5 @@ bump: minor - A lane that already passed at the same tree, command and runtime is reused instead of rerun (`gstack-evidence run --reuse`, the default from `gstack-ship-preflight` and `finish`): a newer failure vetoes an older pass, and the reused row keeps its original timestamp, so the 24-hour age limit still measures the real run. - /ship runs beside CI: on a repo that maps its lanes, Phase 2 pushes early (`gstack-ship-release push --early` runs the secrets scan and the sealed guard first), `finish` defers a mapped lane it cannot carry yet (`EVIDENCE: DEFERRED`), and the new `gstack-ship-release accept` carries it from the green run at the final pushed head and reruns the pregate there. `gstack-ci-carry find|wait` checks or waits for that run, and the ship receipt records `ci_carried`. See docs/pregate.md#ci-carry. - On a small machine, the remote gate obligation clears from the same carried run (through `remote_gate_runs` and its inventory check) when the files left over are only ones the gate skips, such as gstack's paid evals. Before, that remainder made the gate report `tests=0` and `clear --run` refuse it as `paid_only`, so nothing could clear it. +- On gstack itself, a green Free Tests run at the same tree is now the default way the remote gate obligation clears, and the Ubicloud VM (`gstack-pregate --stage tests --remote`) is an opt-in. The uncleared obligation names the CI route first: push, `gstack-ci-carry wait --base main`, then rerun the tests stage. A branch behind main dispatches Free Tests on the branch, because a `pull_request` run tests the merge commit, another tree. +- `gstack-evidence record-ci` now finds and verifies the CI run itself (`record-ci --label --base -- `), so no caller can hand it run ids or a test count. `finish` writes its preflight-only pregate run to its own file, so the full `.gstack/tmp/pregate.json` stays in place until `accept` replaces it. diff --git a/docs/pregate.md b/docs/pregate.md index 2772e398c..5f3028b9d 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -494,8 +494,13 @@ the selected local files as a `requires-remote` obligation with the command to run, `pregate.json/remote_gate` (the declared command) or `test_backend/` (gstack's `scripts/ubicloud/ubi-runner.sh run --src -- ' '`). Without either, the tier runs locally under -the supervision above. Close the obligation one of three ways: +the supervision above. Close the obligation one of four ways: +- by default, when the base declares `remote_gate_runs`, let CI clear it: the + tests stage clears it on its own from a verified run at this exact tree + ([CI carry](#ci-carry)), and the uncleared obligation's detail names that + route first (`push, then gstack-ci-carry wait --base and rerun --stage + tests`) and the VM only as the opt-in; or - after running it, `gstack-pregate clear --remote pregate.json/remote_gate --receipt ` with the gate's output, validated as `--remote` validates it (a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt; a receipt @@ -951,6 +956,22 @@ receipt instead of becoming an obligation nothing can clear. The line is pregate.json/remote_gate stays uncleared ()` when a check refuses, for example a gate inventory file missing from the run's `tree.json`. +**gstack's own gate.** gstack's `.gstack/pregate.json` declares +`remote_gate_runs` with the `ci-reuse/free-tests` profile, so on gstack a green +Free Tests run at the same tree is how the remote gate obligation clears, and +the Ubicloud VM (`gstack-pregate --stage tests --remote`) is an opt-in for when +no such run exists. The order after a push: wait for Free Tests +(`gstack-ci-carry wait --base main`), then `gstack-pregate --stage tests +--allow-repo-commands`. A `pull_request` run tests GitHub's merge commit, so on +a branch that does not contain the base tip it tested another tree and +`CI_CARRY` says `tree_mismatch`: dispatch Free Tests on the branch +(`gh workflow run free-tests.yml --ref `), which tests HEAD's own tree, +and wait again. The preflight stage (generated-file freshness, secrets, strays +and the other mechanical checks) and the sealed guard are unchanged and always +run here. The macOS and Windows lane +obligations still clear with `gstack-pregate clear --remote --run-url +` from their CI runs. + **/ship lanes: `ship_lanes_from`.** A lane carries only when the base branch's `.gstack/pregate.json` maps its exact command (the evidence ledger binds the command's sha256; a label alone never carries) to the profiles whose runs carry diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index b3cb73b0b..e7c842f1f 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -533,7 +533,10 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local'); if (smallMachine) { const obligation = remoteObligation(ctx, sel); - return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap --allow-repo-commands` }; + const route = obligation.lane === REMOTE_GATE_LANE && ctx.config.remote_gate_runs + ? `a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base ${ctx.base} and rerun --stage tests (a pull_request run of a branch behind its base tested the merge, another tree: dispatch the workflow on the branch); or opt in to the VM: gstack-pregate --remote --spend-cap --allow-repo-commands` + : `run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap --allow-repo-commands`; + return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); ${route}` }; } if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' }; if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` }; diff --git a/test/pregate-carry-gate.test.ts b/test/pregate-carry-gate.test.ts index da02dfe80..28d6567e7 100644 --- a/test/pregate-carry-gate.test.ts +++ b/test/pregate-carry-gate.test.ts @@ -76,4 +76,19 @@ describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate o expect(gate(file)?.receipt).toBeNull(); expect(progress).toContainEqual(expect.stringMatching(new RegExp(`^CI_CARRY: ${LANE} stays uncleared \\(REUSE: refused reason=inventory_uncovered`))); }, 120_000); + + // Value: protects=when the base declares remote_gate_runs, the uncleared gate names the CI route first and the VM only as an opt-in; + // fails_when=the obligation sends an agent to the paid VM while a CI run at the same tree would clear it; why_new=GSTA-258 A, plan choice F1 + test('with remote_gate_runs on the base, an uncleared gate names the CI route first and the VM as the opt-in', async () => { + const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: { 'ci-reuse/free-tests': PROFILE }, remote_gate_runs: { profiles: ['ci-reuse/free-tests'], inventory: 'echo test/retry.test.js' } } }); + repos.push(repo); + commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = 2;\n' }, 'touch retry'); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const file = await runPregate(loaded.ctx, { stage: 'tests' }); + expect(gate(file)?.receipt).toBeNull(); + const detail = file.checks.find(c => c.id === 'lanes')?.detail ?? ''; + expect(detail).toContain(`a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base main and rerun --stage tests`); + expect(detail.indexOf('gstack-ci-carry wait')).toBeLessThan(detail.indexOf('opt in to the VM: gstack-pregate --remote --spend-cap --allow-repo-commands')); + }, 120_000); });