mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
ship accept: record-ci bound from base ci_record_timeout_s; say when the final pregate ran tests here (GST-1606) (#4065)
* test: failing-first accept record-ci timeout from base policy and no-carry pregate line (GST-1606) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship): accept bounds record-ci by base ci_record_timeout_s and says when the final pregate ran tests here (GST-1606) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ci_record_timeout_s and accept-local-tests (GST-1606) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes The accept record-ci timeout test's run profile, tree.json os and workflow runner follow the host, so the accepted-carry case holds on macOS too (fromRunProfiles matches the profile os to the host; a runner for another OS is a platform lane). Review-Finding: v1/outside-voice#1 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2305526774
commit
37f81cf19d
9 files changed
+145
-11
No files matched your search
@@ -0,0 +1 @@
|
||||
- **`/ship` accept's `record-ci` bound comes from the base ship policy, and accept says when its final pregate ran the tests here.** accept stopped every `gstack-evidence record-ci` at a fixed 600 s, so on harness-evals #271, whose record-ci needed about 745 s, the lane ran record-ci and the rest of accept by hand. The new `ci_record_timeout_s` key in `.gstack/ship-policy.json` sets the bound (default 600, at least 60, clamped to 3600). accept reads it from the base branch only and prints `CI_RECORD_TIMEOUT: <n>s (…)`. The same run's final pregate found no run profile for this OS and ran 3,666 tests locally with no reason given. When the tests stage gets no CI carry, accept now prints `CI_CARRY: none (<reason>); the final pregate's tests stage ran here instead: tests <verdict> in <n>s`. A profile without `os` carries `ship_lanes_from` lanes but not the tests stage. See docs/ship-policy.md#ci-record-timeout and docs/pregate.md#accept-local-tests. (GST-1606)
|
||||
+15
-1
@@ -1217,7 +1217,21 @@ each mapped lane from the green run at that exact tree, reuses or runs the rest,
|
||||
checks every lane FRESH and reruns the pregate there, whose tests stage carries
|
||||
the same run. `gstack-ship-receipt write` records the carried runs as
|
||||
`ci_carried: [{ lane, run, attempt, tree }]`, and the `SHIP_RECEIPT:` line names
|
||||
them (`ci_carried=<lane>:<run>/<attempt>`).
|
||||
them (`ci_carried=<lane>:<run>/<attempt>`). Each `record-ci` gets the base
|
||||
policy's [`ci_record_timeout_s`](ship-policy.md#ci-record-timeout) (600 s by
|
||||
default, 3600 s at most), and accept prints it as `CI_RECORD_TIMEOUT:`.
|
||||
|
||||
<a id="accept-local-tests"></a>**When accept's final pregate runs the tests here (GST-1606).** The final
|
||||
pregate's tests stage carries only under a base run profile whose `os` matches
|
||||
this machine and whose workflow holds a lane this machine runs. A lane carried
|
||||
through `ship_lanes_from` needs neither, so a profile without `os` carries
|
||||
every lane and still leaves the tests stage to run its whole selection here
|
||||
(harness-evals #271 ran 3,666 tests that way). accept then prints one line:
|
||||
`CI_CARRY: none (<reason>); the final pregate's tests stage ran here instead:
|
||||
tests <verdict> in <n>s`. The run is correct, only slower. Adding `"os":
|
||||
"Linux"` (or this machine's OS) to the profile on the base branch lets the
|
||||
next accept carry the tests stage too, when the run's `tree.json` covers the
|
||||
selection.
|
||||
|
||||
<a id="carry-imported-tests"></a>**An inventory file CI's JUnit tree never lists (GST-1595).** bun registers
|
||||
the tests of a test file another test file imports under the importer, so the
|
||||
|
||||
@@ -70,6 +70,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
|
||||
| `release_gate` | `{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd }` or `null` | `null` | One full paid gate on main every `cadence_hours` (8 to 168) instead of the per-push main backstop, feeding the stable release; see [release_gate](#release-gate). Every field is required: `on_red` is `hold-release`, `bisect_spend_cap_usd` 0 to 500 (0 skips the bisect). `null` keeps the per-push backstop. Needs `min_gstack_version`. |
|
||||
| `bookkeeping` | glob list | `[]` | <a id="bookkeeping"></a>Root-anchored globs of bookkeeping tables a verify or ratchet step rewrites (size baselines, serial lists, limits), each ending in `.tsv`, `.csv` or `.txt` and never under `.gstack/` or `.github/` (`["scripts/*-baseline.tsv"]`). A matching file changed at most 20 lines after review binds, first touched or not, takes finish's [one scoped reader](review-policy.md#bounded-post-review-edits) instead of Steps 5–11.5; the reader still reads every changed row. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `mechanical_retake` | `[{ path, template, fields, regen }]` | `[]` | A version or rules constant a branch may have to retake after a base merge, next to `bookkeeping`'s bounded kind: `path` is one literal file, `template` the whole line with one `{n}` (`const RULE_NUMBERS := {n}`), `fields` the `{ glob, field }` fixture fields that carry the same value, and `regen` the [`.gstack/generated.json`](pregate.md#generated) entry ids whose command regenerates its goldens. A post-review edit finish proves to be only that retake routes as `mechanical=`, with no reader and no review round; see [mechanical retakes](#mechanical-retake). At most 8 constants and 16 fields or ids each; no glob, `..`, `.gstack/` or `.github/` path. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `ci_record_timeout_s` | integer seconds, at least 60 | `600` | <a id="ci-record-timeout"></a>How long `gstack-ship-release accept` lets one `gstack-evidence record-ci` run before it stops that lane. A value over 3600 is clamped to 3600. accept prints the bound it used as `CI_RECORD_TIMEOUT: <n>s (default)`, `(origin/<base> ci_record_timeout_s)` or `(… <value> is over the 3600s ceiling)`. Raise it when record-ci runs long, for example when it reruns a per_file remainder of CI-skipped files here (harness-evals #271 needed about 745 s). Read from the base revision only, so a branch can't raise its own bound. Needs `min_gstack_version`. |
|
||||
| `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version (under `merge: direct`, the [release PR](#release-stamp) does). `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. |
|
||||
|
||||
|
||||
+11
-3
@@ -14,7 +14,7 @@ import { laneMapping, recordCiResult, type ShipLaneMapping } from './ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
|
||||
import { parseUnifiedDiff, versionAtRef } from './restamp';
|
||||
import { branchSides, stampOnlyLine } from './stamp-only';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { DEFAULT_POLICY, matchesAny } from './ship-policy';
|
||||
import { extractVersion } from './version-source';
|
||||
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
|
||||
|
||||
@@ -198,13 +198,21 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
|
||||
* A mapped lane carried from CI at HEAD's clean tree: gstack-evidence record-ci finds and verifies the run (lib/ci-carry.ts)
|
||||
* and records the CI-sourced ledger row; or why it was not (`carryNone`), so the caller runs it.
|
||||
*/
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }, timeoutS = DEFAULT_POLICY.ci_record_timeout_s): LaneResult {
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], timeoutS * 1000);
|
||||
const got = recordCiResult(rec.stderr);
|
||||
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}), ...(got.exception ? { exception: got.exception } : {}) };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}`, ...(got.diagnosis ? { diagnosis: got.diagnosis } : {}) };
|
||||
}
|
||||
|
||||
/** accept's bound on one record-ci (GST-1606): the base policy's `ci_record_timeout_s`, 600 s by default, clamped to 3600 s. */
|
||||
export function recordCiTimeout(policy: Record<string, any> | null, base: string): { seconds: number; line: string } {
|
||||
const set = policy?.ci_record_timeout_s;
|
||||
if (typeof set !== 'number' || set === DEFAULT_POLICY.ci_record_timeout_s) return { seconds: DEFAULT_POLICY.ci_record_timeout_s, line: `CI_RECORD_TIMEOUT: ${DEFAULT_POLICY.ci_record_timeout_s}s (default)` };
|
||||
if (set > 3600) return { seconds: 3600, line: `CI_RECORD_TIMEOUT: 3600s (origin/${base} ci_record_timeout_s ${set} is over the 3600s ceiling)` };
|
||||
return { seconds: set, line: `CI_RECORD_TIMEOUT: ${set}s (origin/${base} ci_record_timeout_s)` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each lane, at most `concurrency` at a time; results keep the lanes' order. With `base`, a lane the base branch maps in
|
||||
* `ship_lanes_from` is carried from a verified CI run at this exact tree when one exists. Every other lane runs through
|
||||
|
||||
@@ -47,6 +47,7 @@ export const LAND_KEY_VERSION = '1.96.0.0';
|
||||
export const READERS_KEY_VERSION = '1.96.0.0';
|
||||
export const BOOKKEEPING_KEY_VERSION = '1.96.0.0';
|
||||
export const MECHANICAL_RETAKE_KEY_VERSION = '1.96.1.0';
|
||||
export const CI_RECORD_TIMEOUT_KEY_VERSION = '1.97.0.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
@@ -56,5 +57,5 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, release_gate: RELEASE_GATE_KEY_VERSION,
|
||||
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION, land: LAND_KEY_VERSION, readers: READERS_KEY_VERSION,
|
||||
bookkeeping: BOOKKEEPING_KEY_VERSION, mechanical_retake: MECHANICAL_RETAKE_KEY_VERSION,
|
||||
bookkeeping: BOOKKEEPING_KEY_VERSION, mechanical_retake: MECHANICAL_RETAKE_KEY_VERSION, ci_record_timeout_s: CI_RECORD_TIMEOUT_KEY_VERSION,
|
||||
};
|
||||
+4
-1
@@ -99,6 +99,7 @@ export interface ShipPolicy {
|
||||
/** Constants a branch may retake after a base merge, with their fixture fields and golden regen ids (docs/ship-policy.md#mechanical-retake); read from the base only. */ mechanical_retake: MechanicalConstant[];
|
||||
/** `direct` lands each READY PR at its own green head (`land --pin`, docs/ship-policy.md#land-direct); `train` keeps merge trains. */ merge: 'direct' | 'train';
|
||||
/** What `land --pin` runs on the pin merged onto a base that moved past its merge base (lib/land-merged-checks.ts); null lands a stale base untested with a STALE_BASE: warn line. */ land: LandPolicy | null;
|
||||
/** Seconds accept gives each `gstack-evidence record-ci` (docs/ship-policy.md#ci-record-timeout), clamped to 3600; read from the base only. */ ci_record_timeout_s: number;
|
||||
}
|
||||
|
||||
/** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */
|
||||
@@ -190,7 +191,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
|
||||
train: null,
|
||||
docs: null,
|
||||
base_red: null, merge: 'train', land: null, bookkeeping: [], mechanical_retake: [],
|
||||
release_gate: null, readers: { forbid_fetch: [], forbid_paths: [], brief_note: null },
|
||||
release_gate: null, readers: { forbid_fetch: [], forbid_paths: [], brief_note: null }, ci_record_timeout_s: 600,
|
||||
};
|
||||
|
||||
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
|
||||
@@ -229,6 +230,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
land: LAND_EXPLANATION,
|
||||
bookkeeping: 'Root-anchored globs of bookkeeping tables, each ending in .tsv, .csv or .txt and never under .gstack/ or .github/, for example ["scripts/*-baseline.tsv"]. An edit of at most 20 lines to a matching file after review binds, first touched or not, takes /ship finish\'s scoped reader instead of Steps 5–11.5; the reader still reads every changed row. Read from the base branch only. Empty (default) declares none.',
|
||||
mechanical_retake: MECHANICAL_EXPLANATION,
|
||||
ci_record_timeout_s: 'Seconds `gstack-ship-release accept` lets one `gstack-evidence record-ci` run before it stops that lane, an integer of at least 60 (default 600). A value over 3600 is clamped to 3600, and accept prints the bound it used (`CI_RECORD_TIMEOUT:`). Raise it when record-ci runs long, for example a per_file remainder of CI-skipped files. Read from the base branch only.',
|
||||
merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr <n> --pin <sha>` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).',
|
||||
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
|
||||
};
|
||||
@@ -470,6 +472,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors);
|
||||
if ('bookkeeping' in obj) listErrors('bookkeeping', obj.bookkeeping, errors, g => globError(g) ?? bookkeepingGlobError(g));
|
||||
if ('mechanical_retake' in obj) mechanicalErrors(obj.mechanical_retake, errors, globError);
|
||||
if ('ci_record_timeout_s' in obj && !(Number.isInteger(obj.ci_record_timeout_s) && (obj.ci_record_timeout_s as number) >= 60)) errors.push({ code: 'POLICY_INVALID', path: 'ci_record_timeout_s', message: 'must be an integer of at least 60 seconds (accept clamps a value over 3600 to 3600)' });
|
||||
if ('restamp_command' in obj && obj.restamp_command !== null) {
|
||||
const t = obj.restamp_command;
|
||||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' });
|
||||
|
||||
+11
-5
@@ -30,7 +30,7 @@ import { waitForRun } from './ci-carry';
|
||||
import { lanesOnlyHeadPolicy } from './pregate/head-policy';
|
||||
import { PREPUSH_CHECK_IDS } from './pregate/index';
|
||||
import { buildRunCheck } from './pregate/run-check';
|
||||
import { bin, carryLane, carryNoneLines, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
|
||||
import { bin, carryLane, carryNoneLines, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, recordCiTimeout, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
|
||||
|
||||
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
|
||||
|
||||
@@ -573,10 +573,12 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
if (w.state === 'red') return r.stop(`${p.workflow} run ${w.run.id} failed (${short(w.failing, 4) || 'no failing job named'}): Step 5 triage on its logs; fix, commit, push, then accept again`);
|
||||
if (w.state === 'none') return r.stop(`${w.reason}: push the branch (a pull_request or workflow_dispatch run must exist at HEAD), then accept again`);
|
||||
}
|
||||
const recordCi = recordCiTimeout(shipPolicy(cwd, base), base);
|
||||
r.add(recordCi.line);
|
||||
const exceptions: string[] = [];
|
||||
for (const l of list) {
|
||||
const m = mapped.get(l.label);
|
||||
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
const res = m ? carryLane(cwd, base, l, recordCi.seconds) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||||
if (m && !res.carried) { for (const line of carryNoneLines(res)) r.add(line); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
|
||||
const ran = laneLine(res);
|
||||
r.add(ran.line);
|
||||
@@ -590,9 +592,13 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
|
||||
const policy = lanesOnlyHeadPolicy(cwd, base);
|
||||
if (policy.line) r.add(policy.line);
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, ...(policy.head ? ['--policy-from', 'head'] : [])], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
const carried = /^CI_CARRY: .*$/m.exec(pg.stdout + pg.stderr)?.[0];
|
||||
if (carried) r.add(carried.length > 240 ? `${carried.slice(0, 240)}…` : carried);
|
||||
const out = pg.stdout + pg.stderr;
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(out)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
const clip = (line: string, n: number) => (line.length > n ? `${line.slice(0, n)}…` : line);
|
||||
const carried = /^CI_CARRY: (?:accepted |none \().*$/m.exec(out)?.[0];
|
||||
const tests = /^PREGATE: tests (.+?) in ([\d.]+)s/m.exec(out);
|
||||
if (carried && (carried.startsWith('CI_CARRY: accepted') || !tests)) r.add(clip(carried, 240));
|
||||
else if (tests) r.add(`${carried ? clip(carried, 200) : 'CI_CARRY: none (the pregate policy declares no run_profiles)'}; the final pregate's tests stage ran here instead: tests ${tests[1]} in ${tests[2]}s (docs/pregate.md#accept-local-tests)`);
|
||||
r.add(`PREGATE: ${verdict} (final tree)`);
|
||||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the final tree: fix it, then rerun accept`);
|
||||
return r.next(`Step 18: the receipt records ci_carried from the ledger${exceptions.length ? `; the PR body carries ${exceptions.map(e => JSON.stringify(`Test-lane exception: ${e}`)).join(' and ')} verbatim, one line each, or the receipt write refuses` : ''}`);
|
||||
|
||||
@@ -415,6 +415,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-accept-lane-exception.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence, gstack-approvals and gstack-ship-receipt (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-accept-record-timeout.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-evidence, gstack-ship-policy and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-accept-head-policy.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-mechanical-retake.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-review-plan, gstack-ship-receipt, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-finish-stack-parent.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42156939158)',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* GST-1606: gstack-ship-release accept bounds each `gstack-evidence record-ci` by the base ship policy's
|
||||
* `ci_record_timeout_s` (600 s by default, 3600 s at most) and prints the value it used, and it says when the final
|
||||
* pregate's tests stage got no CI carry and ran here. harness-evals #271: its CI takes about 745 s, so record-ci hit the
|
||||
* hard 600 s cap, and the final pregate ran 3,666 tests locally with no line saying why.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { CI_RECORD_TIMEOUT_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
|
||||
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const HOST_OS = ({ darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[process.platform] ?? 'Linux';
|
||||
const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record<string, string>)[process.platform] ?? 'ubuntu-latest';
|
||||
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: HOST_OS };
|
||||
const TESTS = ['test/a.test.ts'];
|
||||
const pregate = (profile: Record<string, unknown>) => JSON.stringify({
|
||||
run_profiles: { 'ci/free': profile }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: `printf '${TESTS.join('\\n')}\\n'` } }, dependencies: { 'src/a.ts': ['test/a.test.ts'], 'src/b.ts': ['test/a.test.ts'], 'test/**': [], '.gstack/ship-policy.json': [] },
|
||||
}, null, 2) + '\n';
|
||||
const ship = (timeout: number) => JSON.stringify({ ci_record_timeout_s: timeout, min_gstack_version: CI_RECORD_TIMEOUT_KEY_VERSION }, null, 2) + '\n';
|
||||
const BASE = {
|
||||
'.gstack/pregate.json': pregate(PROFILE),
|
||||
'.github/workflows/free.yml': `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ${RUNNER}\n steps:\n - run: bun test\n`,
|
||||
'test/a.test.ts': "import { expect, test } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n",
|
||||
};
|
||||
|
||||
function fx(base: Record<string, string>, head: Record<string, string> = {}): ShipFx {
|
||||
const f = shipFixture({ ...BASE, ...base }, { 'src/b.ts': 'export const b = 2;\n', ...head });
|
||||
fixtures.push(f);
|
||||
const sha = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: sha, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] }, trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha, tree, files: TESTS, tests: 1, os: HOST_OS } } };
|
||||
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
|
||||
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
|
||||
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
|
||||
return f;
|
||||
}
|
||||
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
|
||||
describe('accept: record-ci timeout from the base ship policy (GST-1606)', () => {
|
||||
// Value: protects=a repo whose record-ci runs past 600 s (harness-evals #271, CI about 745 s) raises the bound on its base branch and
|
||||
// accept uses it; fails_when=accept keeps the hard 600_000 ms cap or does not say which bound it used; seam=bin/gstack-ship-release accept
|
||||
test('the base sets ci_record_timeout_s: accept uses it and says so', () => {
|
||||
const out = accept(fx({ '.gstack/ship-policy.json': ship(900) }));
|
||||
expect([out.status, lineOf(out.stdout, 'CI_RECORD_TIMEOUT')], out.stdout).toEqual([0, 'CI_RECORD_TIMEOUT: 900s (origin/main ci_record_timeout_s)']);
|
||||
});
|
||||
|
||||
// Value: protects=one policy line cannot hold accept for hours: the bound stops at 3600 s; fails_when=accept passes 7200 s through;
|
||||
// seam=recordCiTimeout
|
||||
test('a value over the 3600 s ceiling is clamped to it', () => {
|
||||
const out = accept(fx({ '.gstack/ship-policy.json': ship(7200) }));
|
||||
expect([out.status, lineOf(out.stdout, 'CI_RECORD_TIMEOUT')], out.stdout).toEqual([0, 'CI_RECORD_TIMEOUT: 3600s (origin/main ci_record_timeout_s 7200 is over the 3600s ceiling)']);
|
||||
});
|
||||
|
||||
// Value: protects=the trust boundary: a branch cannot raise its own gate's bound; fails_when=accept reads the head's ship policy;
|
||||
// seam=shipPolicy (gstack-ship-policy show-effective --base)
|
||||
test('a value only the branch sets is ignored: accept keeps the 600 s default', () => {
|
||||
const out = accept(fx({}, { '.gstack/ship-policy.json': ship(900) }));
|
||||
expect([out.status, lineOf(out.stdout, 'CI_RECORD_TIMEOUT')], out.stdout).toEqual([0, 'CI_RECORD_TIMEOUT: 600s (default)']);
|
||||
});
|
||||
|
||||
// Value: protects=a malformed bound fails validation by name instead of silently falling back; fails_when=validatePolicy accepts a
|
||||
// string, a fraction or a value under 60, or the key without min_gstack_version; seam=lib/ship-policy.ts validatePolicy
|
||||
test('validation: an integer of at least 60 seconds, behind min_gstack_version', () => {
|
||||
const codes = (raw: Record<string, unknown>) => validatePolicy(raw).map(e => `${e.code}:${e.path}`);
|
||||
const at = (v: unknown) => codes({ ci_record_timeout_s: v, min_gstack_version: CI_RECORD_TIMEOUT_KEY_VERSION });
|
||||
expect([at(60), at(7200)]).toEqual([[], []]);
|
||||
for (const bad of ['900', 59, 600.5, null]) expect(at(bad), String(bad)).toEqual(['POLICY_INVALID:ci_record_timeout_s']);
|
||||
expect(codes({ ci_record_timeout_s: 900 })).toEqual(['POLICY_INVALID:ci_record_timeout_s']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accept: the final pregate says when its tests stage ran without a CI carry (GST-1606)', () => {
|
||||
// Value: protects=the operator sees why accept's final pregate ran the selected tests here (harness-evals #271 ran 3,666 with no
|
||||
// reason given); fails_when=accept relays only the bare CI_CARRY none line or nothing; seam=bin/gstack-ship-release accept
|
||||
test('a run profile without os carries the lane but not the tests stage: one line says why the tests ran here', () => {
|
||||
const { os: _os, ...noOs } = PROFILE;
|
||||
const out = accept(fx({ '.gstack/pregate.json': pregate(noOs) }));
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([0, expect.stringMatching(/^CI_CARRY: none \(no_profile: [^)]*\); the final pregate's tests stage ran here instead: tests pass in \d+(\.\d+)?s \(docs\/pregate\.md#accept-local-tests\)$/)]);
|
||||
});
|
||||
|
||||
// Value: protects=a carried tests stage keeps its plain CI_CARRY line; fails_when=the local-run note is added to an accepted carry;
|
||||
// seam=bin/gstack-ship-release accept
|
||||
test('a profile for this OS carries the tests stage: the accepted line stands alone', () => {
|
||||
const out = accept(fx({}));
|
||||
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted /)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user