diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 0ee86ab23..8fc3e692b 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -122,6 +122,8 @@ interface EvidenceRecord { source?: "ci"; runs?: string[]; profiles?: string[]; + /** The base policy that mapped a carried lane (record-ci), as `origin/@:.gstack/pregate.json`. */ + policy?: string; /** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */ tests_ran?: number | "unknown"; } @@ -671,10 +673,10 @@ function cmdRecordCi(argv: string[]): number { const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`); const rec: EvidenceRecord = { ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0, - commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, + commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, }; appendJsonl(paths.file, rec, { mode: 0o600 }); - console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")}`); + console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`); return 0; } diff --git a/changelog.d/gsta-258-a-ci-carry.md b/changelog.d/gsta-258-a-ci-carry.md index 95515a0a9..1de4d0863 100644 --- a/changelog.d/gsta-258-a-ci-carry.md +++ b/changelog.d/gsta-258-a-ci-carry.md @@ -5,5 +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. +- 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 `gstack-pregate --allow-repo-commands`. On a branch behind main, dispatch Free Tests on the branch (`gh workflow run free-tests.yml --ref `), 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 5f3028b9d..326fb61ac 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -499,8 +499,10 @@ 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 + route first (`push, then gstack-ci-carry wait --base and rerun + gstack-pregate --allow-repo-commands`, the whole pregate, so the record keeps + its preflight checks) and the VM only as the opt-in (under `--no-ci-carry` it + names the VM route); 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 @@ -961,10 +963,11 @@ example a gate inventory file missing from the run's `tree.json`. 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 +(`gstack-ci-carry wait --base main`), then `gstack-pregate --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 +`CI_CARRY` says `none (no_verified_run: … 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 diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index e7c842f1f..a16f4b0ed 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -533,8 +533,8 @@ 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); - 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` + const route = obligation.lane === REMOTE_GATE_LANE && ctx.config.remote_gate_runs && ctx.ciCarry !== false + ? `a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base ${ctx.base} and rerun gstack-pregate --allow-repo-commands (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}` }; } diff --git a/test/pregate-carry-gate.test.ts b/test/pregate-carry-gate.test.ts index 28d6567e7..eb0390d90 100644 --- a/test/pregate-carry-gate.test.ts +++ b/test/pregate-carry-gate.test.ts @@ -88,7 +88,11 @@ describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate o 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).toContain(`a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base main and rerun gstack-pregate --allow-repo-commands`); expect(detail.indexOf('gstack-ci-carry wait')).toBeLessThan(detail.indexOf('opt in to the VM: gstack-pregate --remote --spend-cap --allow-repo-commands')); + loaded.ctx.ciCarry = false; + const off = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')?.detail ?? ''; + expect(off).not.toContain('gstack-ci-carry wait'); + expect(off).toContain('run it, then gstack-pregate clear --remote pregate.json/remote_gate'); }, 120_000); }); diff --git a/test/ship-ci-carry.test.ts b/test/ship-ci-carry.test.ts index 7c560a93d..60e494dca 100644 --- a/test/ship-ci-carry.test.ts +++ b/test/ship-ci-carry.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs'; +import { findFilesBySuffix } from './helpers/scratch-repo'; import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; setDefaultTimeout(120_000); @@ -102,7 +103,9 @@ describe('Step 5 lanes: CI carry and the tree cache', () => { expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH'); expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]); const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]); - expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1')]); + expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1 policy=origin/main@')]); + const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!); + expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/); }); });