diff --git a/changelog.d/capy-gst-1606-record-ci-timeout.md b/changelog.d/capy-gst-1606-record-ci-timeout.md new file mode 100644 index 000000000..f187d565a --- /dev/null +++ b/changelog.d/capy-gst-1606-record-ci-timeout.md @@ -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: 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 (); the final pregate's tests stage ran here instead: tests in 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) diff --git a/docs/pregate.md b/docs/pregate.md index 5cd0dda1d..76a99c2a9 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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=:/`). +them (`ci_carried=:/`). 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:`. + +**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 (); the final pregate's tests stage ran here instead: +tests in 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. **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 diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 32faf4018..5f20bdeea 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 | `[]` | 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` | 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: s (default)`, `(origin/ ci_record_timeout_s)` or `(… 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`. | diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 507edc391..0a690ac7b 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -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 | 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 diff --git a/lib/ship-policy-keys.ts b/lib/ship-policy-keys.ts index 472b74aa4..3712fcac6 100644 --- a/lib/ship-policy-keys.ts +++ b/lib/ship-policy-keys.ts @@ -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 = { 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 = { 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, }; diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 1558ea338..ce0996a43 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -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; @@ -229,6 +230,7 @@ export const POLICY_EXPLANATIONS: Record = { 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 --pin ` (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' }); diff --git a/lib/ship-release.ts b/lib/ship-release.ts index bbe854386..ee9b7edd0 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -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 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` : ''}`); diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 2303f35b6..816a614ec 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -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)', diff --git a/test/ship-accept-record-timeout.test.ts b/test/ship-accept-record-timeout.test.ts new file mode 100644 index 000000000..f2099cee8 --- /dev/null +++ b/test/ship-accept-record-timeout.test.ts @@ -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)[process.platform] ?? 'Linux'; +const RUNNER = ({ darwin: 'macos-latest', win32: 'windows-latest' } as Record)[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) => 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, head: Record = {}): 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) => 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 /)]); + }); +});