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$/);
});
});