v1.91.238.0 fix(ship): gate-diff base in single-branch clones, gstack-post release versions and receipt ids, evidence tests_ran, restamp duplicate headings and stamp prompt (GSTA-102) (#3439)

* fix(gate-diff): resolve the base to origin/<base> when no local branch exists; the no_base fix line fetches into the tracking ref

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(post): the repo's release versions and a receipt's tree hash / run ids are not pii.ip_public or pii.phone.e164

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evidence): parse the shard runner's and the remote pregate lane's test counts into tests_ran

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): CHANGELOG_DUPLICATE_HEADING refuses only duplicates the PR introduces

checkChangelog takes the base branch's CHANGELOG: a heading the base already
carries N times may appear N times on the PR (inherited released history), and
only a further copy is a duplicate. gstack-restamp passes origin/<base>'s
CHANGELOG at the plan's base sha, so a base like gbrain's master, which heads
one version twice, can be stamped; a duplicate the PR adds is still refused
with nothing written.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): the stamp-at-merge next: line no longer asks the lane to edit the CHANGELOG

Under a train the stamp commit may hold only restamp's own output: the train's
verify accepts the CHANGELOG only as the base's plus the PR's own entry
re-headed, and a lane that followed the old "write the CHANGELOG entry text"
prompt froze the queue. The claim-at-open wording is unchanged.

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:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 09:33:07 +00:00
1 parent fa9d4b637c
commit 7ee7e8bdd4
22 files changed
+1068 -52

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [1.91.238.0] - 2026-10-26
**Five /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
### Fixed
- **`gstack-gate-diff <base>` in a clone with no local `<base>` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD.
- **`gstack-post` no longer flags the repository's own release versions as public IPs.** A four-part version is byte-for-byte a dotted quad, so "(1.91.191.0)" in a PR body raised `pii.ip_public` (MEDIUM) and stopped an unattended docs audit that cannot confirm a MEDIUM finding. The engine's version exemption only reads the declaration right before the span (`"version": "`, `## [`), so prose that merely names a release got no help. Now `lib/gstack-post.ts` reads the root `VERSION` value and every version in a root `CHANGELOG.md` release heading (`## [1.2.3.4]`, `## v1.2.3.4`, through the same `parseChangelog` gstack-restamp uses) and passes those exact strings to the scan's allowlist. Verdict: this narrows one detector by the repo's own release set, and nothing else. The allowlist is exact-string and context-free, so a released quad is exempt wherever it appears in the text, `host: 1.91.191.0` included; the set is the repo's own releases and nothing wider. `8.8.8.8` and `203.0.113.7` in prose, a quad the repo never released (`1.128.1.0`), and the same text outside a repository or in a repo whose VERSION is three-part all still need confirmation; `test/gstack-post.test.ts` carries those controls beside the four benign bodies (`(1.91.191.0)`, `v1.91.226.0`, a bare `1.91.191.0`, `release 1.91.190.0`) against a synthetic CHANGELOG in which `1.91.192.0` is not a release. The same post path also stops reading a ship receipt's all-digit tree hash (`tree=704992822996`) and an 11-digit GitHub Actions run id in prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`) as `pii.phone.e164`: only the exact tokens in those positions are allowlisted, and `+14155551234`, a bare 11-digit number and a 10-digit number after `run` still need confirmation (GSTA-94 hit this on #3424).
- **`gstack-evidence` records the test count of gstack's shard runner and of the remote pregate lane.** `bun run test:free` / `test:ubicloud` run quiet shards that forward bun's `Ran N tests` line without its ` N pass` counts, so the bun parser read every full-suite run as `tests_ran: 0` and /ship's Step 16 saw a zero-run suite (GSTA-94 on #3424); the remote lane prints `PREGATE_REMOTE: tree=… tests=9660 failed=0` (a lane row under `lanes:` in the pregate table) and `requires-remote: … cleared by remote-gate tree=… tests=9660`, which no parser knew, so it was `tests_ran: unknown` and the ship receipt and the Review Readiness Dashboard never carried the number. Now a bun terminal line with no counts block above it is not a bun summary (bun always prints ` 0 pass` / ` 0 fail`, even for an all-skip run, which still grades ZERO-RUN), the shard runner's per-shard `[test:free] PASS — N tests` epilogues are summed for the last run in the output (a files-from run's `FILES-FROM RESULT: … tests=N` is the same sum; a run with a `FAIL —` or `FLAKY-PASS —` epilogue and no such total has lost a shard's count and is recorded as unknown, not a partial sum), and when the shard runner spoke its verdict stands for the whole tail (the tail is the stdout tail then the stderr tail, so a line's position cannot order bun's stderr counts block against the runner's stdout epilogues; a focused `bun test` run before the suite in the same command no longer records its count, or ZERO-RUN, for the 10k-test run that followed), and the remote lane's two lines are parsed as whole lines like every other runner pattern (a prefixed or quoted echo does not count; the later line wins when both appear); a paid-only selection's `tests=0` is ZERO-RUN, not a pass. Honest limit: the shard runner and the remote gate print only bun's terminal totals, which include skipped tests, so for those two the number is tests ran with skips included, not executed (the shard runner refuses a shard whose bun summary counts fewer files than planned, but a shard whose files all loaded and executed nothing still passes with that total). The exit code is still never changed by the parse. `test/evidence.test.ts` runs the wrapper over `test/fixtures/evidence/pregate-remote-failed.out`, the stdout of a real `gstack-pregate --stage tests --remote` run captured under gstack-evidence on 2026-10-26 (9658 tests, 1 failed, recorded as `tests_ran=9658` with `exit=1`, never FRESH), over a cleared lane's table (`tests_ran=9660`, FRESH), over the receipt line alone, over the two lines with differing counts in both orders, and over `test/fixtures/evidence/shard-runner-files-from.out`, the remote gate's own shard-runner stream for this branch (275 files, 16 shards, 10138 tests: the epilogue sum equals the `FILES-FROM RESULT` total, with and without that final line, and a forwarded `Ran N tests` line on stderr no longer zeroes it).
- **`gstack-restamp` refuses only the CHANGELOG duplicates the PR introduces.** `CHANGELOG_DUPLICATE_HEADING` compared the branch's headings with themselves, so a base branch whose released history already heads one version twice (gbrain's `master` carries two `## [0.38.0.0]` sections, and its own check requires every one of them to stay) could never be stamped. The check now takes the base branch's CHANGELOG, read at `origin/<base>` and at the commit the branch's own lines are counted from (its fork point, or the predecessor's head in a gate-ahead stamp), the larger count per heading winning: a heading the base carries N times may appear N times on the PR, and only a further copy is refused, with a message that says how many the base has. When a reading is unavailable (the PR adds the CHANGELOG itself) the stage detail and the refusal name it. A duplicate the PR adds, on a clean base or beside an inherited one, is still refused with nothing written. Tests: a fixture whose `main` heads `1.2.2.0` twice stamps and keeps both copies, and still does after `main` collapsed the copies behind the PR's back; a PR that adds a third copy, one that adds the first duplicate on a clean base, and one whose new CHANGELOG on a base without one heads a version twice, are refused. `docs/troubleshooting.md` says which duplicates count.
- **`gstack-restamp --next` under a train no longer asks the lane to edit the CHANGELOG.** Its `next:` line told every stamp to "write the CHANGELOG entry text"; a lane that obeyed on #3422 added a release summary under the new heading, the train's verify refused the head (the CHANGELOG must equal the base's plus the PR's own entry re-headed) and the queue froze. Under `queue_mode stamp-at-merge` the line now says to commit and push only the stamp diff with no edit of the lane's own; the claim-at-open wording is unchanged, since that bump still writes its entry at /ship's CHANGELOG step. Test: the stamp-at-merge fixture's output carries no instruction to edit the CHANGELOG by hand, and the claim-at-open fixture still does.
## [1.91.237.0] - 2026-10-26
**An unstamped PR can change a release output again (GSTA-96 on #3434).** Regenerating `agents-digest/gstack-AGENTS.md` after a generator change made the Version Gate classify the head as stamped (the digest is in `release_outputs`) and demand base + bump, while changing only `scripts/gen-agents-digest.ts` failed `test/agents-digest.test.ts`'s freshness check, so a digest rewording could not land before its slot.
+1 -1
View File
@@ -1 +1 @@
1.91.237.0
1.91.238.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.237.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.238.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+99 -7
View File
@@ -19,9 +19,24 @@
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
* is parsed from a bounded tail of the streamed output for the supported
* runners only: bun (shard-engine's summary parser where importable; executed =
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, and Go's
* zero-only case (every package `[no test files]`). The last summary per
* runner wins; anything else is "unknown". Three states, never merged:
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, Go's
* zero-only case (every package `[no test files]`), gstack's own shard runner
* (`bun run test:free` / `test:ubicloud`: the sum of its per-shard
* `[test:free] PASS — N tests` epilogues, or a files-from run's
* `FILES-FROM RESULT: … tests=N`), and a `gstack-pregate --remote` lane (the
* remote gate's `PREGATE_REMOTE: … tests=N` row, or the cleared obligation's
* `cleared by remote-gate … tests=N` receipt). The shard runner and the remote
* gate print only bun's terminal totals, which count skipped tests, so for
* those two the number is "ran, skips included" rather than executed (the shard
* runner refuses a shard whose bun summary counts fewer files than planned, but
* a shard whose files all loaded and executed nothing still passes with that
* total). When the shard runner spoke, its verdict stands for the whole tail:
* the tail is the stdout tail then the stderr tail, so a line's position says
* nothing about when it was printed across streams, and bun's own counts (on
* stderr) would otherwise outrank a 10k-test run whichever of the two ran
* first. Any summary when it did not speak follows the last-summary-wins rule;
* anything else is "unknown".
* Three states, never merged:
* a count, 0 (ZERO-RUN, graded like STALE by `check`) and unknown (non-blocking).
*
* TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the
@@ -76,7 +91,7 @@ interface EvidenceRecord {
wtree?: string;
log_path?: string;
redacted?: boolean;
/** Executed tests per the runner's own summary; "unknown" when no supported summary was seen. */
/** 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";
}
@@ -108,7 +123,17 @@ const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, "");
const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0);
/** Bun: the last "Ran N tests across M files" block; executed = its " N pass" + " N fail" lines. */
/**
* Bun: the last "Ran N tests across M files" block; executed = its " N pass" +
* " N fail" lines. Bun always prints that counts block (" 0 pass" / " 0 fail"
* even for an all-skip or empty run), so a terminal line with no " N pass"
* above it is a line gstack's quiet shard runner forwarded (it forwards the
* terminal line without the " N pass" line), not a bun summary:
* it is skipped and the shard runner's own epilogue counts. A terminal line
* that itself says "Ran 0 tests" is ZERO-RUN even without counts, unless the
* shard runner spoke: a forwarded "Ran 0 tests across 1 file" is one shard's
* empty file, not the run.
*/
async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number } | null> {
let isTerminal = (l: string) => /^Ran \d+ tests? across \d+ files?\. \[/.test(l);
try {
@@ -119,7 +144,7 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
}
for (let i = lines.length - 1; i >= 0; i--) {
if (!isTerminal(lines[i])) continue;
let pass = 0;
let pass: number | null = null;
let fail = 0;
for (let j = i - 1; j >= 0 && j >= i - 12; j--) {
if (isTerminal(lines[j])) break;
@@ -128,11 +153,64 @@ async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number }
if (p) pass = Number(p[1]);
if (f) fail = Number(f[1]);
}
if (pass === null) {
if (/^Ran 0 tests? across/.test(lines[i])) return { at: i, ran: 0 };
continue;
}
return { at: i, ran: pass + fail };
}
return null;
}
/**
* gstack's shard runner (scripts/test-free-shards.ts; `bun run test:free` /
* `test:ubicloud`). Its quiet shards forward bun's terminal line without the
* counts the bun branch reads, so each shard's epilogue "[test:free] PASS — N
* tests, M files, Ts." carries the shard's "Ran N" total (skips included) and
* the run's total is their sum; a files-from run ends with "[test:free]
* FILES-FROM RESULT: files=F tests=N failed=K exit=E", the same sum. Only the
* last run in the tail counts (its "[test:free] full suite:" / "files-from" /
* "QUICK SUBSET" start line); the other parsers decide only when no runner
* line at all is in the tail. A run with a "FAIL —" or "FLAKY-PASS —" epilogue and no FILES-FROM
* total has lost a shard's count (a FAIL epilogue carries none; the flaky retry
* re-runs only the failing files), so it is unknown rather than a partial sum.
* The runner's verdict covers the whole tail once it spoke: the tail is
* stdout-then-stderr, bun's counts block is on stderr and the runner's
* epilogues are on stdout, so "after" cannot be told from "before" across the
* two streams and a bun block printed before the suite (a focused file run
* first) would otherwise record its count, or ZERO-RUN, for a 10k-test run.
* The quiet shards forward only count-less terminal lines (a shard's "Ran 0
* tests across 1 file" is one empty file, not the run); under `--verbose` they
* forward whole blocks, which the runner's sum outranks. The epilogue is
* matched as the runner's whole line (the log path may contain spaces); like
* every runner summary here it is the child's own stdout, so a test that
* forges one is trusted the same way a forged bun summary is (see W3's trust
* model), and the exit is never derived from it. Returns undefined when no
* runner line is in the tail. A verdict line (PASS/FAIL/FLAKY-PASS epilogue or
* FILES-FROM RESULT) without its start line means the start line was evicted
* from the tail (a chatty default-mode or --verbose run): the runner spoke,
* the sum is incomplete, so the result is unknown rather than a forwarded
* shard block's count. A full-suite start line names its shard count
* ("across N shard processes", plus one exclusive host-state shard when it
* says so); fewer PASS epilogues than that (killed mid-shards, `--ci-verify`)
* is unknown, never a partial sum. An empty shard prints "shard i/N: 0 files,
* 0s, pass" instead of a PASS epilogue and counts as zero.
*/
function shardRunnerTestsRan(lines: string[]): number | "unknown" | undefined {
const verdictLine = /^\s*\[test:free\] (?:PASS — |FAIL — |FLAKY-PASS — |FILES-FROM RESULT: )/;
const start = lines.findLastIndex((l) => /^\s*\[test:free\] (?:full suite:|files-from |QUICK SUBSET:)/.test(l));
if (start < 0) return lines.some((l) => verdictLine.test(l)) ? "unknown" : undefined;
const run = lines.slice(start);
const result = /^\s*\[test:free\] FILES-FROM RESULT: files=\d+ tests=(\d+) failed=\d+ exit=\d+\s*$/;
const total = run.findLast((l) => result.test(l));
if (total) return Number(result.exec(total)![1]);
if (run.some((l) => /^\s*\[test:free\] (?:FAIL|FLAKY-PASS) — /.test(l) || /^\s*\[test:free\] PASS — \? tests/.test(l))) return "unknown";
const counts = run.map((l) => /^\s*\[test:free\] PASS — (\d+) tests?, \d+ files?, \d+s\. Full log: \S.*$/.exec(l)?.[1] ?? (/^\s*\[test:free\] shard \d+\/\d+: 0 files, \d+s, pass\s*$/.test(l) ? "0" : undefined)).filter((n): n is string => n !== undefined);
const planned = /across (\d+) shard processes/.exec(run[0]!);
const expected = planned ? Number(planned[1]) + (/exclusive host-state file/.test(run[0]!) ? 1 : 0) : 1;
return counts.length >= expected ? counts.reduce((sum, n) => sum + Number(n), 0) : "unknown";
}
function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | null {
const hits: { at: number; ran: number }[] = [];
const last = (re: RegExp) => {
@@ -162,14 +240,28 @@ function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | nu
const goNoTests = lines.filter((l) => /^\?\s+\S+\s+\[no test files\]$/.test(l));
const goRan = lines.some((l) => /^(?:ok|FAIL|---)\s/.test(l));
if (goNoTests.length > 0 && !goRan) hits.push({ at: last(/\[no test files\]$/), ran: 0 });
// gstack-pregate --remote: the remote gate's own "PREGATE_REMOTE: tree=… tests=N failed=M"
// line (tests = the shard runner's FILES-FROM RESULT sum, relayed by scripts/ubicloud/pregate-remote.sh;
// lib/pregate/checks/lanes.ts parseRemoteReport is the canonical parser), rendered as a lane
// row under "lanes:" in the pregate table, and the obligation's "requires-remote: … cleared by
// remote-gate tree=… tests=N" receipt line (lib/pregate/index.ts). Whole lines, like every
// other runner pattern here, so a prefixed or quoted echo does not count (the row is indented
// under "lanes:" by design, so indentation is accepted). When both appear they carry the same
// count; the later one wins.
for (const re of [/^\s*(?:lanes:\s+)?PREGATE_REMOTE: tree=\S+ tests=(\d+) failed=\d+(?: spent=\S+)?\s*$/, /^\s*requires-remote: .* cleared by remote-gate tree=\S+ tests=(\d+)\b/]) {
i = last(re);
if (i >= 0) hits.push({ at: i, ran: Number(re.exec(lines[i])![1]) });
}
if (hits.length === 0) return null;
return hits.reduce((a, b) => (b.at > a.at ? b : a));
}
/** The executed-test count from the output tail, or "unknown". Never throws into the caller's exit path. */
/** The runner-reported test count from the output tail (see the header for which runners report executed vs ran), 0, or "unknown". Never throws into the caller's exit path. */
async function parseTestsRan(tail: string): Promise<number | "unknown"> {
if (process.env.GSTACK_EVIDENCE_FAULT === "tests_ran") throw new Error("injected tests_ran parser fault");
const lines = tail.split("\n").map(strip);
const shard = shardRunnerTestsRan(lines);
if (shard !== undefined) return shard;
const candidates = [await bunTestsRan(lines), otherRunnersTestsRan(lines)].filter((c): c is { at: number; ran: number } => c !== null);
if (candidates.length === 0) return "unknown";
return candidates.reduce((a, b) => (b.at > a.at ? b : a)).ran;
+5 -4
View File
@@ -29,7 +29,7 @@ import * as os from 'node:os';
import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index';
import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolvesToCommit, treePaths, untrackedAsAdded,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
import { resolveStateRoot } from '../lib/state-root';
@@ -142,13 +142,14 @@ function main(argv: string[]): number {
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
} else {
const baseRef = parsed.base ?? defaultBase(root);
const baseSha = resolvesToCommit(baseRef, root);
const head = headSha(root);
if (!baseSha || !head) {
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const mb = mergeBase(baseSha, head, root);
if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
+1
View File
@@ -97,6 +97,7 @@ function report(r: RestampResult, json: boolean): number {
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
else if (r.status === 'stamped' || r.status === 'noop') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; record the release decision, then write the CHANGELOG entry text only (the heading is already ${r.newVersion}); merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
}
process.stdout.write(formatResult('restamp', status, r.journal ?? JOURNAL_REL) + '\n');
+24 -8
View File
@@ -178,8 +178,11 @@ draft are cut: the artifact path is tool-owned, a child's self-reported
path list is both a pathspec-injection surface and a self-report, and the
only history need is calibration, which `--commit <sha>` covers by scanning
one commit's first-parent delta without the working tree.) Positional
base defaults the way `gstack-diff-scope` does (`origin/HEAD` → `main` →
`master`); an already-qualified ref is used as given. stdout: one
base defaults the way `gstack-diff-scope` does (the remote's `HEAD` target,
origin first, then the other remotes → `main` → `master`); an
already-qualified ref is used as given, and a branch name with no local
branch resolves through its remote-tracking twin (`origin/main` for `main`:
the merge-base is the same commit), noted once on stderr. stdout: one
`GATE_SUMMARY: listed=N read=N tagged={RH-1:2,RH-15:1} unmatched=N
coverage=<patterns> languages_unlisted=<…> candidate=<sha+wtree>` line, then
the read-level listing (bounded; the full listing goes to `--out`).
@@ -207,9 +210,10 @@ hunks and 200 KB; `GATE_SUMMARY` reports `eligible`, `inspected`,
like UNAVAILABLE, and never as `none detected`.
**Exit contract.** 0 with or without hunks; 2 only when the base is
unresolvable (`GATE_ERROR=no_base ref=<ref>` with the concrete recovery:
`git fetch <remote> <branch>` from the detected remote, or "deepen the
shallow history") or the tool itself failed. Unmatched paths are reported
unresolvable locally and as `<remote>/<ref>` (`GATE_ERROR=no_base ref=<ref>`
with the concrete recovery: `git fetch <remote> +<branch>:refs/remotes/<remote>/<branch>`
into the remote-tracking ref, `--unshallow` on a shallow clone, or a plain
fetch/deepen for a sha or relative ref) or the tool itself failed. Unmatched paths are reported
in `GATE_SUMMARY: unmatched=N` and listed, never an exit 2. The template
block that calls it branches on the exit explicitly, never
`2>/dev/null || true`:
@@ -350,9 +354,21 @@ a test count natively.
saved log is capped at 2 MB and would truncate the summary) and parses
it into `tests_ran` for the supported runners only: bun (reusing
`shard-engine`'s classifier; executed = pass + fail, so an all-skip run is
ZERO-RUN), jest/vitest, pytest, rspec, and Go's zero-only case (every
package `[no test files]`); the last summary per runner wins. Everything
else is `unknown`. `check` reports ZERO-RUN as STALE-equivalent and
ZERO-RUN), jest/vitest, pytest, rspec, Go's zero-only case (every
package `[no test files]`), gstack's shard runner (the sum of its per-shard
`[test:free] PASS — N tests` epilogues, or a files-from run's
`FILES-FROM RESULT: … tests=N`), and a `gstack-pregate --remote` lane (its
`PREGATE_REMOTE: tree=… tests=N failed=M` row or the obligation's
`cleared by remote-gate tree=… tests=N` receipt line); when the shard runner
spoke its verdict stands for the whole tail (the tail is stdout then stderr,
so position cannot order bun's stderr counts against the runner's stdout
epilogues), and any summary when it did not speak follows the
last-summary-wins rule. Everything else is `unknown`. The shard runner and the
remote gate print only bun's terminal totals, which count skipped tests, so
for those two `tests_ran` is ran-with-skips rather than executed: the shard
runner refuses a shard whose bun summary counts fewer files than planned, but
a shard whose files all loaded and executed nothing still passes with that
total. `check` reports ZERO-RUN as STALE-equivalent and
unknown as non-blocking. The TRANSPARENCY INVARIANT holds: `run`'s exit is
always the child's; detection lives in the receipt and in `check`.
- After Step 7's test-generation child returns and after Step 9.4's parent
+20 -2
View File
@@ -2244,7 +2244,11 @@ entry that no longer applies; rotate a credential that is real.
<a id="redact-version-as-ip"></a>
### `pii.ip_public` MEDIUM on a four-part version number
**Meaning.** The line does not declare the value as a version.
**Meaning.** The line does not declare the value as a version. When the
text goes through `gstack-post` from inside the repository, four-part versions
equal to the root `VERSION` or to a `## [x.y.z.w]` / `## vx.y.z.w` CHANGELOG.md heading are
exempt wherever they appear, so there the finding means the quad is not a
release of this repository (or the post ran outside the checkout).
**Fix.** Write it as `version: 1.2.3.4`, `"version": "..."` or `v1.2.3.4`.
MEDIUM findings do not block pushes.
@@ -2620,8 +2624,22 @@ read the receipt again.
**Meaning.** Two sections share one version heading, usually a stamp that
re-headed `[Unreleased]` beside an entry that already carried the version.
In `gstack-restamp` only duplicates the PR introduces count: the headings are
compared against the base branch's CHANGELOG as read at `origin/<base>` and at
the commit the branch's own lines are counted from (its fork point, or the
predecessor's head in a gate-ahead stamp); the larger count per heading wins,
so a version the base already heads twice (released history the PR inherited
and must keep) passes as long as the PR adds no further copy of it. When a
reading is unavailable (the PR adds the CHANGELOG itself, or the file moved),
the stage detail and the refusal name it: with no reading left every duplicate
counts, otherwise inherited headings are counted at the readable commit only. Two paths keep the strict rule: the section being re-headed itself
(`[Unreleased]`, or the branch's earlier stamp) may exist only once, and
`gstack-version-bump write --changelog` (the claim-at-open bump) compares the
file with itself.
**Fix.** Merge them into one entry under the new version.
**Fix.** Merge the duplicate sections the PR introduced into one entry under
the new version. A duplicate that is on the base branch itself is not this
PR's to fix in a restamp; leave it and the stamp proceeds.
<a id="changelog-top-mismatch"></a>
### `CHANGELOG_TOP_MISMATCH`
+30 -8
View File
@@ -2,8 +2,9 @@
* changelog-check — CHANGELOG re-heading and checks for gstack-restamp (C2)
* and the tree receipt (C3). A restamp moves the PR's section (`[Unreleased]`
* or the version currently stamped on the branch) to the top under the new
* version, above its predecessor's entry. Duplicate version headings and a
* top entry that is not VERSION are errors. The tree receipt uses
* version, above its predecessor's entry. Duplicate version headings beyond
* those the base branch already carries, and a top entry that is not VERSION,
* are errors. The tree receipt uses
* `normalizeChangelog` to accept a heading move as a stamp, never a change.
*/
import type { ResultCodeName } from './result-codes';
@@ -44,15 +45,36 @@ export function parseChangelog(text: string): ParsedChangelog {
return { header, sections };
}
/** Duplicate headings, and (when `expectedTop` is given) the top entry equals VERSION. */
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md'): ChangelogError[] {
/**
* Duplicate headings, and (when `expectedTop` is given) the top entry equals
* VERSION. `base` holds readings of the base branch's CHANGELOG (the base tip,
* and the commit the PR's own lines are counted from): a heading it already carries N
* times may appear N times here (history the PR inherited and may not
* rewrite); only an occurrence beyond that count is a duplicate the PR
* introduced. The count is per heading, not per section body: an inherited
* copy the PR edits or replaces is caught by the train's released-history
* checks (`ownEntryAboveMain`, `changelogDamage`), not here.
*/
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md', base: string[] = []): ChangelogError[] {
const errors: ChangelogError[] = [];
const parsed = parseChangelog(text);
const seen = new Map<string, number>();
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
const inherited = new Map<string, number>();
for (const reading of base) {
const counts = new Map<string, number>();
for (const s of parseChangelog(reading).sections) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
for (const [k, n] of counts) inherited.set(k, Math.max(inherited.get(k) ?? 0, n));
}
const seen = new Map<string, number[]>();
for (const s of parsed.sections) {
const key = s.unreleased ? 'Unreleased' : s.version!;
if (seen.has(key)) errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${key} already headed at line ${seen.get(key)! + 1}` });
else seen.set(key, s.start);
const k = key(s);
const starts = seen.get(k) ?? [];
const allowed = Math.max(1, inherited.get(k) ?? 0);
if (starts.length >= allowed) {
const where = allowed > 1 ? `headed ${starts.length + 1} times (the base carries it ${allowed} times; first at line ${starts[0]! + 1})` : `already headed at line ${starts[0]! + 1}`;
errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${k} ${where}` });
}
seen.set(k, [...starts, s.start]);
}
if (expectedTop) {
const top = parsed.sections[0];
+68 -9
View File
@@ -46,12 +46,35 @@ export function resolvesToCommit(ref: string, cwd: string): string | null {
return gitText(['rev-parse', '--verify', '-q', `${ref}^{commit}`], cwd);
}
/** origin/HEAD's target → `main` → `master` (the gstack-diff-scope chain), as a ref name. */
/**
* A ref that could be a branch name: not a sha, not relative or symbolic
* (`HEAD~1`, `main^2`, `@{u}`, `HEAD`, `FETCH_HEAD`), not a full `refs/` path.
* Only such a ref has a remote-tracking twin (`origin/<ref>`) worth trying, and
* only such a ref goes into a fix-line refspec; `origin/HEAD~1` would resolve
* to a different commit than the `HEAD~1` the caller named.
*/
function isBranchName(ref: string): boolean {
return !/^[0-9a-f]{7,64}$/.test(ref) && !/[~^]|@\{/.test(ref) && !/^(?:[A-Z_]*HEAD)$/.test(ref) && !ref.startsWith('refs/');
}
function remoteNames(cwd: string): string[] {
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
return remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
}
/**
* The remote's HEAD target → `main` → `master` (the gstack-diff-scope chain),
* as a ref name; the remotes are walked in resolveBase's order, so the default
* and the fix line come from the same remote.
*/
export function defaultBase(cwd: string): string {
const head = gitText(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], cwd);
if (head) return head.replace(/^refs\/remotes\//, '');
const remotes = remoteNames(cwd);
for (const remote of remotes) {
const head = gitText(['symbolic-ref', '-q', `refs/remotes/${remote}/HEAD`], cwd);
if (head) return head.replace(/^refs\/remotes\//, '');
}
for (const name of ['main', 'master']) {
if (resolvesToCommit(name, cwd) || resolvesToCommit(`origin/${name}`, cwd)) return name;
if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
}
return 'main';
}
@@ -60,14 +83,50 @@ export function isShallow(cwd: string): boolean {
return gitText(['rev-parse', '--is-shallow-repository'], cwd) === 'true';
}
/** The concrete recovery for an unresolvable base: fetch it from the detected remote, unshallowing when needed. */
/**
* The base as a ref that resolves here and, when `head` is given, shares
* history with it: the ref itself, else its remote-tracking branch
* (`origin/main` for `main`). A clone that checked out only the feature
* branch has no local `main`, and the merge-base against `origin/main` is the
* same commit; a stale local `main` that shares no history with HEAD (a
* fetched-over clone) yields to the twin too, so the fix line's fetch into the
* tracking ref is a fix and not a loop.
*/
export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
const direct = resolvesToCommit(ref, cwd);
if (direct && related(direct)) return { ref, sha: direct };
if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
for (const remote of remoteNames(cwd)) {
const tracking = `${remote}/${ref}`;
const sha = resolvesToCommit(tracking, cwd);
if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' };
}
return direct ? { ref, sha: direct } : null;
}
/** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */
function shellWord(s: string): string {
return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
}
/**
* The concrete recovery for an unresolvable base: fetch it from the detected
* remote into its remote-tracking ref (which resolveBase then finds; a bare
* `git fetch origin main` only updates FETCH_HEAD in a single-branch clone),
* unshallowing when needed. The refspec is forced (`+`) so the line also
* repairs a tracking ref that exists but shares no history with HEAD. A sha
* or a relative ref (`HEAD~1`, `main^2`, `@{u}`) is not a refspec: that gets a
* plain fetch (deepened when shallow).
*/
export function noBaseFix(ref: string, cwd: string): string {
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
const remotes = remoteNames(cwd);
const m = /^([^/]+)\/(.+)$/.exec(ref);
const remote = m && remotes.includes(m[1]) ? m[1] : remotes.includes('origin') ? 'origin' : remotes[0] ?? 'origin';
const remote = m && remotes.includes(m[1]) ? m[1] : remotes[0] ?? 'origin';
const branch = m && remotes.includes(m[1]) ? m[2] : ref;
if (/^[0-9a-f]{7,64}\^?$/.test(ref) || ref.endsWith('^')) return isShallow(cwd) ? `git fetch --deepen=50 ${remote}` : `git fetch ${remote}`;
return isShallow(cwd) ? `git fetch --unshallow ${remote} ${branch}` : `git fetch ${remote} ${branch}`;
if (!isBranchName(ref)) return isShallow(cwd) ? `git fetch --deepen=50 ${shellWord(remote)}` : `git fetch ${shellWord(remote)}`;
const refspec = shellWord(`+${branch}:refs/remotes/${remote}/${branch}`);
return isShallow(cwd) ? `git fetch --unshallow ${shellWord(remote)} ${refspec}` : `git fetch ${shellWord(remote)} ${refspec}`;
}
export function mergeBase(a: string, b: string, cwd: string): string | null {
+56 -3
View File
@@ -16,6 +16,8 @@
import { createHash } from "crypto";
import { scan, type Finding, type RepoVisibility } from "./redact-engine";
import { canonicalRemote } from "./remote-identity";
import { parseChangelog } from "./changelog-check";
import { versionWidth } from "./version-source";
export const OPS = ["pr-comment", "issue-comment", "reply", "pr-title", "pr-body", "pr-create", "issue-create"] as const;
export type Op = (typeof OPS)[number];
@@ -198,11 +200,61 @@ function visibility(env: PostEnv, req: Request, kind: HostKind, configBin: strin
type Part = "title" | "body";
interface Located { part: Part; finding: Finding }
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined) {
/**
* The repository's own four-part release versions: the root VERSION file's
* value and every version in a root CHANGELOG.md release heading, read with
* the same parser gstack-restamp uses (`## [1.2.3.4]`, `## v1.2.3.4`, an
* optional `- date`; a heading that only mentions a quad is not one). A
* four-part version is byte-for-byte a dotted quad, and `1.91.191.0` is a
* public address, so "(1.91.191.0)" in a PR body raised pii.ip_public
* (MEDIUM), which an unattended /ship cannot confirm. The engine's own version
* exemption reads only the declaration right before the span, so prose that
* merely names a release gets no help from it. These exact strings are
* allowlisted for the scan wherever they appear (the allowlist is exact-string
* and context-free, so `host: <released quad>` is exempt too, a trade the
* repo's own release set is small enough to carry); any other dotted quad
* (8.8.8.8, 203.0.113.7, a version this repo never released) still reports.
* The files are read from the checked-out tree, so a branch that adds a
* four-part heading widens the set for posts made from that checkout.
*/
function repoReleaseVersions(env: PostEnv): string[] {
const top = env.run("git", ["rev-parse", "--show-toplevel"]);
const root = top.status === 0 ? top.stdout.trim() : "";
if (!root) return [];
const read = (name: string) => { try { return env.readFile(`${root}/${name}`); } catch { return ""; } };
const versions = new Set<string>();
const current = read("VERSION").trim();
if (versionWidth(current) === 4) versions.add(current);
for (const s of parseChangelog(read("CHANGELOG.md")).sections) if (s.version && versionWidth(s.version) === 4) versions.add(s.version);
return [...versions];
}
/**
* All-digit identifiers a ship receipt or a CI link names, which pii.phone.e164
* reads as phone numbers: a 12-hex receipt sha that happens to be all digits
* (`tree=704992822996`, `tree 704992822996`, and the receipt's `head=`,
* `gated_tree=` and `predecessor=` keys) and an 11-digit GitHub Actions
* run id as prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`;
* the engine already exempts the id inside the URL itself). Only the exact
* tokens found in those positions are allowlisted, so a bare 11-digit number,
* a 10-digit national number and an E.164 `+14155551234` still report.
*/
function receiptDigitTokens(texts: string[]): string[] {
const tokens = new Set<string>();
for (const text of texts) {
for (const m of text.matchAll(/(?:^|[^\w])(?:tree|gated_tree|head|predecessor)[= ](\d{12})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\brun #?(\d{11})\b/g)) tokens.add(m[1]!);
for (const m of text.matchAll(/\/actions\/runs\/(\d{11})\b/g)) tokens.add(m[1]!);
}
return [...tokens];
}
function scanParts(parts: Array<[Part, string]>, vis: RepoVisibility, selfEmail: string | undefined, releaseVersions: string[]) {
const found: Located[] = [];
let oversize = false;
const allowlist = [...releaseVersions, ...receiptDigitTokens(parts.map(([, text]) => text))];
for (const [part, text] of parts) {
const result = scan(text, { repoVisibility: vis, ...(selfEmail ? { selfEmail } : {}) });
const result = scan(text, { repoVisibility: vis, allowlist, ...(selfEmail ? { selfEmail } : {}) });
oversize ||= result.oversize;
for (const finding of result.findings) found.push({ part, finding });
}
@@ -303,7 +355,8 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
const vis = visibility(env, req, remote.kind, configBin);
const email = env.run("git", ["config", "user.email"]);
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined);
const hasQuad = parts.some(([, text]) => /\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text));
const { found, oversize } = scanParts(parts, vis, email.status === 0 ? email.stdout.trim() || undefined : undefined, hasQuad ? repoReleaseVersions(env) : []);
env.out(`REPO_VISIBILITY: ${vis}\n`);
const line = (m: Located) => `RULE: ${m.finding.id} LINE: ${m.finding.line} PART: ${m.part}\n`;
const high = found.filter(m => m.finding.severity === "HIGH");
+13 -3
View File
@@ -380,9 +380,19 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
if (r.moved) fs.writeFileSync(clPath, r.text);
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!);
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`;
// Inherited duplicates are allowed at the larger count of two base readings: the base tip and
// `since`, the commit the branch's own lines are counted from (its fork point, or the predecessor's
// head in a gate-ahead stamp), so neither a duplicate main collapsed after the fork nor one it
// gained since refuses the PR. A reading the base cannot show (the PR adds the file) is named.
const readings = [...new Set([plan.baseSha, since])].map(sha => ({ sha, cl: git(o.repoRoot, ['show', `${sha}:${clRel}`]) }));
const readable = readings.filter(x => x.cl.ok);
const unreadable = readings.filter(x => !x.cl.ok).map(x => `${x.sha.slice(0, 12)}:${clRel}`);
const baseNote = unreadable.length === 0 ? '' : readable.length === 0
? `; base ${clRel} unreadable at ${unreadable.join(', ')}, so every duplicate heading counts`
: `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`;
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n'));
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote;
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
injected('changelog');
+2 -2
View File
@@ -226,8 +226,8 @@ export const RESULT_CODES = {
},
CHANGELOG_DUPLICATE_HEADING: {
anchor: 'changelog-duplicate-heading',
summary: 'two CHANGELOG sections carry the same version heading',
fix: 'merge the duplicate sections into one entry under the new version',
summary: 'two CHANGELOG sections carry the same version heading, beyond any duplicate the base branch already carries',
fix: 'merge the duplicate sections the PR introduced into one entry under the new version',
},
CHANGELOG_TOP_MISMATCH: {
anchor: 'changelog-top-mismatch',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.237",
"version": "1.91.238",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+168
View File
@@ -404,6 +404,174 @@ describe('gstack-evidence tests_ran — zero-run green is a third state, never m
}
});
test('a gstack-pregate --remote lane: the remote gate\'s tests= count (ran, skips included) lands in the receipt (captured output)', () => {
// test/fixtures/evidence/pregate-remote-failed.out is the stdout of
// `gstack-pregate --stage tests --remote` captured under gstack-evidence
// on 2026-10-26 (GSTA-102): the Ubicloud gate ran 9658 tests with 1
// failure, and the lane came back incomplete. Before, the receipt said
// tests_ran=unknown for every remote lane, so the ship receipt and the
// Review Readiness Dashboard never carried the real number.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'pregate-remote-failed.out');
expect(fs.readFileSync(fixture, 'utf-8')).toContain('PREGATE_REMOTE: tree=279e658e29bec511739dd00510828dc433fb94fa tests=9658 failed=1 spent=0.04');
const r = run(['run', '--label', 'tests', '--', `cat '${fixture}'; exit 1`]);
expect(r.status).toBe(1);
expect(records().pop()).toMatchObject({ exit: 1, tests_ran: 9658 });
expect(r.stderr).toContain('recorded label=tests exit=1 tests_ran=9658');
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: STALE label=tests exit=1 tests_ran=9658');
});
test('a cleared remote lane (captured output): the gate line and the receipt line carry the same count, and the lane is FRESH', () => {
// pregate-remote-cleared.out is the same lane's stdout+stderr on the next
// tree, after the fix: 9660 tests, 0 failed, the obligation cleared. It
// carries both the gate's PREGATE_REMOTE line and the "cleared by
// remote-gate" receipt, plus bun's own "Ran 23 tests" line indented under
// "lanes:", which must not be mistaken for a bun summary.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'pregate-remote-cleared.out');
const text = fs.readFileSync(fixture, 'utf-8');
expect(text).toContain('PREGATE_REMOTE: tree=46b75c23704fb4ae1f22677258c16b880ecd20c5 tests=9660 failed=0 spent=0.04');
expect(text).toContain('cleared by remote-gate tree=46b75c23704f tests=9660');
expect(text).toContain('lanes: Ran 23 tests across 1 file.');
expect(run(['run', '--label', 'tests', '--', `cat '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: FRESH label=tests exit=0 tests_ran=9660');
// A paid-only selection reports tests=0: that is ZERO-RUN, not a pass.
expect(run(['run', '--label', 'tests', '--', "printf ' lanes: PREGATE_REMOTE: tree=279e658e29bec511739dd00510828dc433fb94fa tests=0 failed=0 spent=0\\n'"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
expect(run(['check', '--label', 'tests']).stdout).toContain('EVIDENCE: ZERO-RUN label=tests exit=0 tests_ran=0');
});
test('the cleared receipt line alone is parsed, the later of the two lines wins, and an echoed occurrence does not count', () => {
// renderTable prints the obligation's receipt line beside the lane row; it is
// parsed on its own (here in the `clear --receipt` form lib/pregate/index.ts
// builds, with receipt= and sha256=) so a table that carries the receipt
// without the row still records the count.
const receipt = 'requires-remote: pregate.json/remote_gate (remote) 362 test(s) — cleared by remote-gate tree=46b75c23704f tests=9660 receipt=/x/gate.out sha256=0123456789ab';
const gate = ' lanes: PREGATE_REMOTE: tree=46b75c23704fb4ae1f22677258c16b880ecd20c5 tests=9658 failed=0 spent=0.04';
const print = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
expect(run(['run', '--label', 'tests', '--', print(receipt)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['run', '--label', 'tests', '--', print(gate, receipt)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9660);
expect(run(['run', '--label', 'tests', '--', print(receipt, gate)]).status).toBe(0);
expect(records().pop().tests_ran).toBe(9658);
// Only a line that is the lane row or the receipt counts: a log line that
// prefixes or quotes one is not a run (indentation is accepted: the row
// itself is indented under "lanes:").
expect(run(['run', '--label', 'tests', '--', print('expected PREGATE_REMOTE: tree=x tests=9999 failed=0', 'note: saw cleared by remote-gate tree=x tests=9999 in a log')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test("gstack's shard runner (captured output): the FILES-FROM RESULT total, the per-shard PASS sum without it, and a forwarded bun line on stderr", () => {
// test/fixtures/evidence/shard-runner-files-from.out is the stream of the
// remote gate's `scripts/test-free-shards.ts --files-from` run on the
// Ubicloud VM for GSTA-102's tree d47be9b6 (275 files, 16 shards, 10138
// tests): 16 "[test:free] PASS — N tests" epilogues whose sum is the
// "FILES-FROM RESULT: … tests=10138" total, nested bun children's own
// "Ran N tests" lines among them. Before, every full-suite run through the
// shard runner (`bun run test:ubicloud`, GSTA-94 on #3424) recorded
// tests_ran=0: the quiet shards forward bun's "Ran N tests" line without
// its " N pass" counts, and the bun branch read that as zero.
const fixture = path.join(ROOT, 'test', 'fixtures', 'evidence', 'shard-runner-files-from.out');
const text = fs.readFileSync(fixture, 'utf-8');
expect(text).toContain('[test:free] FILES-FROM RESULT: files=275 tests=10138 failed=0 exit=0');
expect(text.match(/\[test:free\] PASS — (\d+) tests/g)).toHaveLength(16);
expect(run(['run', '--label', 'tests', '--', `cat '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(10138);
// A full-suite run has no FILES-FROM RESULT line: the per-shard epilogues sum to the same total.
expect(run(['run', '--label', 'tests', '--', `grep -v 'FILES-FROM RESULT' '${fixture}'`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(10138);
// The live shape: epilogues on stdout, the forwarded "Ran N tests" line on stderr (which the
// tail appends after stdout). A bun terminal line with no counts block is not a bun summary.
expect(run(['run', '--label', 'tests', '--', "printf '[test:free] full suite: 1 files across 1 shard processes (duration-packed)\\n[test:free] PASS — 37 tests, 1 files, 38s. Full log: /x\\n'; printf 'Ran 37 tests across 1 file. [38.05s]\\n' >&2"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(37);
// A real bun all-skip run still grades ZERO-RUN: its counts block is present.
expect(run(['run', '--label', 'tests', '--', "printf ' 0 pass\\n 2 skip\\n 0 fail\\nRan 2 tests across 1 file. [4.00ms]\\n'"]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
});
test('shard runner shapes: a forwarded count-less line never beats the epilogue sum, no bun block does once the runner spoke, a flaky-pass run is unknown, only the last run counts, and a count-less "Ran 0 tests" is ZERO-RUN', () => {
const start = '[test:free] full suite: 3 files across 2 shard processes (duration-packed)';
const pass = (n: number) => `[test:free] PASS — ${n} tests, 1 files, 1s. Full log: /x`;
const out = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
const err = (...ls: string[]) => `${out(...ls)} >&2`;
// The quiet runner forwards " N fail" (never " N pass") and the terminal line on stderr.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err(' 1 fail', 'Ran 600 tests across 18 files. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// Once the runner spoke its sum stands over any bun counts block, wherever that block sits: the tail
// is stdout then stderr, so a bun block on stderr always sorts after the runner's stdout epilogues
// whether it ran before the suite (a focused file first) or after it (a trailing all-skip run).
// Value: protects=a 10k-test shard-runner run is never recorded as a focused file's count or ZERO-RUN
// because bun's stderr block out-positions it; fails_when=parseTestsRan lets a counted bun block win
// on position once the runner spoke; why_new=every earlier row put the counted block after the runner;
// seam=none; no_claim=a bun run that genuinely followed the suite is not counted separately.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err(' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), pass(37), ' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', `${err(' 5 pass', ' 0 fail', 'Ran 5 tests across 1 file. [9.00ms]')}; ${out(start, pass(100), pass(37))}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(' 5 pass', ' 0 fail', 'Ran 5 tests across 1 file. [9.00ms]', start, pass(100), pass(37))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// A verdict line whose start line fell out of the tail: the runner spoke but the sum is incomplete, so
// unknown, never a forwarded shard block's count and never bun's ZERO-RUN for a run that was not bun's.
expect(run(['run', '--label', 'tests', '--', `${out(pass(100), pass(37))}; ${err(' 0 pass', ' 2 skip', ' 0 fail', 'Ran 2 tests across 1 file. [4.00ms]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', out('[test:free] FILES-FROM RESULT: files=1 tests=9 failed=0 exit=0')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// A full-suite start line names its shard count: fewer PASS epilogues than that is a killed run, not a total.
const planned = '[test:free] full suite: 300 files across 3 shard processes (duration-packed), then 2 exclusive host-state file(s) serially';
expect(run(['run', '--label', 'tests', '--', out(planned, pass(100), pass(37), pass(5), pass(2))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(144);
expect(run(['run', '--label', 'tests', '--', out(planned, pass(100), pass(37), pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', out('[test:free] full suite: 300 files across 3 shard processes', pass(100), pass(37), pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(142);
// An empty shard prints no PASS epilogue, only its "0 files" line: it counts as a zero shard, not a missing one.
expect(run(['run', '--label', 'tests', '--', out('[test:free] full suite: 2 files across 3 shard processes', pass(100), '[test:free] shard 2/3: 0 files, 0s, pass', pass(37))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// A FAIL epilogue plus a FLAKY-PASS retry exits 0 but has lost the failed shard's count.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] FAIL — 1 failing test(s) in 1 file(s), 0 crashed worker(s). Full log: /y', '[test:free] FLAKY-PASS — 1 file(s) failed once and passed on serial retry: a.test.ts', pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// Two runs in one command: the last run's sum, not both.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), start, pass(7), pass(8))]).status).toBe(0);
expect(records().pop().tests_ran).toBe(15);
// A forwarded "Ran 0 tests" line with no counts block is still ZERO-RUN.
expect(run(['run', '--label', 'tests', '--', err('Ran 0 tests across 0 files. [1ms]')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
// A files-from run's total stands even when a shard failed; a shard with no count is unknown.
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] FAIL — 1 failing test(s) in 1 file(s), 0 crashed worker(s). Full log: /y', '[test:free] FILES-FROM RESULT: files=3 tests=137 failed=1 exit=1')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
expect(run(['run', '--label', 'tests', '--', out(start, pass(100), '[test:free] PASS — ? tests, ? files, 1s. Full log: /z')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test('shard runner degrade paths: no start line, a forwarded empty-file line, a look-alike epilogue, and a run that printed no epilogue', () => {
const start = '[test:free] full suite: 3 files across 2 shard processes (duration-packed)';
const pass = (n: number) => `[test:free] PASS — ${n} tests, 1 files, 1s. Full log: /x`;
const out = (...ls: string[]) => `printf '%s\\n' ${ls.map((l) => `'${l}'`).join(' ')}`;
const err = (...ls: string[]) => `${out(...ls)} >&2`;
// Without the runner's start line in the tail an epilogue still means the runner spoke, but the sum is
// incomplete: unknown, not bun's count (the block may be one forwarded shard's) and not a partial sum.
expect(run(['run', '--label', 'tests', '--', out(' 0 pass', ' 0 fail', 'Ran 0 tests across 0 files. [1ms]', pass(5))]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
expect(run(['run', '--label', 'tests', '--', `${out(pass(100), pass(37))}; ${err('Ran 137 tests across 2 files. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
// A shard's forwarded count-less "Ran 0 tests across 1 file" (an empty file, or a nested child) is not the run.
expect(run(['run', '--label', 'tests', '--', `${out(start, pass(100), pass(37))}; ${err('Ran 23 tests across 1 file. [1s]', 'Ran 0 tests across 1 file. [1ms]', 'Ran 37 tests across 1 file. [1s]')}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(137);
// Only the runner's whole epilogue shape counts (a look-alike without the "Ns. Full log:" tail does not), and a
// checkout path with spaces still matches. A forged whole epilogue is the child's own stdout, trusted like any
// runner summary (W3's trust model), so it is not a control here.
const oneShard = '[test:free] full suite: 1 files across 1 shard processes';
expect(run(['run', '--label', 'tests', '--', out(oneShard, pass(100), '[test:free] PASS — 99999 tests, 1 files, 1s. error: spoof')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(100);
expect(run(['run', '--label', 'tests', '--', out(oneShard, '[test:free] PASS — 100 tests, 2 files, 9s. Full log: /Users/garry tan/gstack/.context/free-test-logs/x.log')]).status).toBe(0);
expect(records().pop().tests_ran).toBe(100);
// The runner spoke but printed no countable epilogue (killed mid-shards): unknown.
expect(run(['run', '--label', 'tests', '--', out(start, '[test:free] shard 1/2 (1 files)')]).status).toBe(0);
expect(records().pop().tests_ran).toBe('unknown');
});
test('the last summary wins when a command runs a suite twice', () => {
expect(run(['run', '--label', 'tests', '--', `${bunOutput(9, 0, 0)}; ${bunOutput(0, 3, 0)}`]).status).toBe(0);
expect(records().pop().tests_ran).toBe(0);
+36
View File
@@ -0,0 +1,36 @@
PREGATE_HEARTBEAT: remote gate pregate.json/remote_gate running 60s; last file none reported; log 972 bytes, last growth 31s ago (/tmp/gstack-pregate-remote-8Y6W83/remote.log)
PREGATE_HEARTBEAT: remote gate pregate.json/remote_gate running 120s; last file none reported; log 31995 bytes, last growth 1s ago (/tmp/gstack-pregate-remote-8Y6W83/remote.log)
PREGATE_HEARTBEAT: remote gate pregate.json/remote_gate running 180s; last file none reported; log 35723 bytes, last growth 7s ago (/tmp/gstack-pregate-remote-8Y6W83/remote.log)
PREGATE_REMOTE_SPEND: spent $0.04, reserved $0, unknown $0 (cap $2)
PREGATE: tests requires-remote (2 uncleared) in 222.3s — lanes: remote gate pregate.json/remote_gate: 9660 tests, 0 failed on tree 46b75c23704f; spend: spent $0.04, reserved $0, unknown $0 (cap $2)
pregate: policy origin/main@8d9501373b55:.gstack/pregate.json; registry origin/main@8d9501373b55:.gstack/generated.json; base origin/main@8d9501373b55; 10 touched file(s); platform linux
stage check status time detail
tests lanes requires-remote 222297 ms remote gate pregate.json/remote_gate: 9660 tests, 0 failed on tree 46b75c23704f; spend: spent $0.04, reserved $0, unknown $0 (cap $2)
lanes: requires-remote free-tests.yml/macos-named-regressions (macos): 2 test(s) — test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts
lanes: requires-remote windows-free-tests.yml/windows-free-shard (windows): 269 test(s) — test/approval-classes.test.ts test/arm-benchmark-selftest.test.ts test/auq-mode-capture.test.ts test/auq-native-capture.test.ts test/auq-substance-panel.test.ts test/auto-decide-fixture.test.ts test/autoplan-dual-voice-fixture.test.ts test/autoplan-review-discovery.test.ts …
lanes: Tiers ran: e2e unit; requires-remote: macos windows
lanes: remote gate: bash scripts/ubicloud/pregate-remote.sh
lanes: log: /tmp/gstack-pregate-remote-8Y6W83/remote.log
lanes: spend: spent $0.04, reserved $0, unknown $0 (cap $2)
lanes: [test:free] PASS — 717 tests, 19 files, 83s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-20-46-128Z-10311-13.log
lanes: Ran 23 tests across 1 file. [108.94s]
lanes: [test:free] shard 1/16: 1 files, 109s, pass
lanes: [test:free] PASS — 23 tests, 1 files, 109s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-20-46-103Z-10311-1.log
lanes: [test:free] FILES-FROM RESULT: files=269 tests=9660 failed=0 exit=0
lanes: PREGATE_VM_TREE_AFTER 8babef7f378613c21777607f14b96a58797bcd13
lanes: ubi-runner: command exited 0 after 110s
lanes: ubi-runner: pull: nothing matches /tmp/gstack-free-test-*
lanes: ubi-runner: destroy requested: eu-central-h1/ubirun-<vm>
lanes: ubi-runner: destroyed eu-central-h1/ubirun-<vm>
lanes: pregate-remote: phases: create 24s, setup 74s, run 110s, teardown 7s; total 220s (target 600s)
lanes: pregate-remote: log: /workspace/gstack/.context/pregate-remote/20261026T011902Z-126497/run.log
lanes: setup rebuilt (committed build output, as the CI free lane rebuilds it): M lib/diagram-render/dist/BUILD_INFO.json
lanes: setup rebuilt (committed build output, as the CI free lane rebuilds it): M lib/diagram-render/dist/diagram-render.html
lanes: PREGATE_REMOTE: tree=46b75c23704fb4ae1f22677258c16b880ecd20c5 tests=9660 failed=0 spent=0.04
stage 2 (test execution): 222297 ms
requires-remote: free-tests.yml/macos-named-regressions (macos) 2 test(s) — uncleared; clear with gstack-pregate clear --remote free-tests.yml/macos-named-regressions --run-url <url>
requires-remote: windows-free-tests.yml/windows-free-shard (windows) 269 test(s) — uncleared; clear with gstack-pregate clear --remote windows-free-tests.yml/windows-free-shard --run-url <url>
requires-remote: pregate.json/remote_gate (remote) 362 test(s) — cleared by remote-gate tree=46b75c23704f tests=9660
PREGATE: requires-remote (2 uncleared) tree=46b75c23704f head=902442f5b2b5 checks=1
pregate.json: /workspace/gstack/.gstack/tmp/pregate.json
GSTACK_RESULT: skill=pregate status=complete run=/workspace/gstack/.gstack/tmp/pregate.json
+32
View File
@@ -0,0 +1,32 @@
pregate: policy origin/main@8d9501373b55:.gstack/pregate.json; registry origin/main@8d9501373b55:.gstack/generated.json; base origin/main@8d9501373b55; 6 touched file(s); platform linux
stage check status time detail
tests lanes incomplete 228702 ms working tree changed during the check (279e658e29be → 9739cb9fe063); result not certified (PREGATE_INCOMPLETE)
lanes: requires-remote free-tests.yml/macos-named-regressions (macos): 2 test(s) — test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts
lanes: requires-remote windows-free-tests.yml/windows-free-shard (windows): 269 test(s) — test/approval-classes.test.ts test/arm-benchmark-selftest.test.ts test/auq-mode-capture.test.ts test/auq-native-capture.test.ts test/auq-substance-panel.test.ts test/auto-decide-fixture.test.ts test/autoplan-dual-voice-fixture.test.ts test/autoplan-review-discovery.test.ts …
lanes: Tiers ran: e2e unit; requires-remote: macos windows
lanes: remote gate: bash scripts/ubicloud/pregate-remote.sh
lanes: log: /tmp/gstack-pregate-remote-drf5AX/remote.log
lanes: spend: spent $0.04, reserved $0, unknown $0 (cap $2)
lanes: [test:free] Recovery: A test or module failed; the root cause is not established. Inspect the full log and repair the cause first. See docs/TESTING_INTERNALS.md.
lanes: [test:free] After repair, focused check: bun test 'test/gate-diff-cli.test.ts'
lanes: [test:free] flaky-retry FAILED — the failures reproduce serially; not flaky.
lanes: [test:free] FILES-FROM RESULT: files=269 tests=9658 failed=1 exit=1
lanes: error: script "test:free" exited with code 1
lanes: PREGATE_VM_TREE_AFTER d355374555c7face87ceb148463d4e4b1af6351a
lanes: ubi-runner: command exited 1 after 117s
lanes: ubi-runner: pull: nothing matches /tmp/gstack-free-test-*
lanes: ubi-runner: destroy requested: eu-central-h1/ubirun-<vm>
lanes: ubi-runner: destroyed eu-central-h1/ubirun-<vm>
lanes: pregate-remote: phases: create 25s, setup 73s, run 117s, teardown 8s; total 227s (target 600s)
lanes: pregate-remote: log: /workspace/gstack/.context/pregate-remote/20261026T005640Z-45511/run.log
lanes: setup rebuilt (committed build output, as the CI free lane rebuilds it): M lib/diagram-render/dist/BUILD_INFO.json
lanes: setup rebuilt (committed build output, as the CI free lane rebuilds it): M lib/diagram-render/dist/diagram-render.html
lanes: PREGATE_REMOTE: tree=279e658e29bec511739dd00510828dc433fb94fa tests=9658 failed=1 spent=0.04
lanes: fix: keep the tree still during the run, then rerun gstack-pregate
stage 2 (test execution): 228702 ms
requires-remote: free-tests.yml/macos-named-regressions (macos) 2 test(s) — uncleared; clear with gstack-pregate clear --remote free-tests.yml/macos-named-regressions --run-url <url>
requires-remote: windows-free-tests.yml/windows-free-shard (windows) 269 test(s) — uncleared; clear with gstack-pregate clear --remote windows-free-tests.yml/windows-free-shard --run-url <url>
requires-remote: pregate.json/remote_gate (remote) 362 test(s) — uncleared; run bash scripts/ubicloud/pregate-remote.sh, then clear with gstack-pregate clear --remote pregate.json/remote_gate --receipt <gate output file>
PREGATE: incomplete tree=279e658e29be head=9e3369bb0f06 checks=1
pregate.json: /workspace/gstack/.gstack/tmp/pregate.json
GSTACK_RESULT: skill=pregate status=incomplete run=/workspace/gstack/.gstack/tmp/pregate.json
+124
View File
@@ -0,0 +1,124 @@
ubi-runner: creating eu-central-h1/ubirun-<vm> (standard-16)
ubi-runner: ready: ubirun-<vm> (<vm-ip>)
ubi-runner: synced /workspace/gstack -> ubirun-<vm>:work/gstack
[... VM setup output omitted ...]
[test:free] files-from /home/ubi/.pregate-files.txt: 275 files, sha256=b67ffd2b99202720a4bd8c9829f8ea1a7007e4b1ad43f0be9da0202c26eb6d36
[test:free] 7 file(s) have no recorded duration and are packed at the 99th-percentile estimate: test/codex-host.test.ts, test/land-and-deploy-version-check.test.ts, test/paid-rerun.test.ts, test/ship-queue-train-logic.test.ts, test/ship-queue-train.test.ts, +2 more. Refresh scripts/free-test-durations.json with `bun run test:ubicloud --record-durations`.
[test:free] WARNING: 7 free test files have no recorded duration (limit 5); they are packed at the 99th-percentile estimate, so shard balance degrades until the seed is refreshed: test/codex-host.test.ts, test/land-and-deploy-version-check.test.ts, test/paid-rerun.test.ts, test/ship-queue-train-logic.test.ts, test/ship-queue-train.test.ts, test/train-backstop.test.ts, test/train-gate.test.ts. Fix: run `bun run test:ubicloud --record-durations` and commit scripts/free-test-durations.json. See docs/TESTING_INTERNALS.md#free-suite-duration-seed.
[test:free] full suite: 275 files across 16 shard processes (duration-packed)
[test:free] shard 1: 1 files, predicted ~83s
[test:free] shard 2: 17 files, predicted ~74s
[test:free] shard 3: 18 files, predicted ~74s
[test:free] shard 4: 18 files, predicted ~74s
[test:free] shard 5: 18 files, predicted ~74s
[test:free] shard 6: 18 files, predicted ~74s
[test:free] shard 7: 18 files, predicted ~74s
[test:free] shard 8: 18 files, predicted ~74s
[test:free] shard 9: 18 files, predicted ~74s
[test:free] shard 10: 18 files, predicted ~74s
[test:free] shard 11: 18 files, predicted ~74s
[test:free] shard 12: 17 files, predicted ~74s
[test:free] shard 13: 19 files, predicted ~74s
[test:free] shard 14: 19 files, predicted ~74s
[test:free] shard 15: 20 files, predicted ~74s
[test:free] shard 16: 20 files, predicted ~74s
[test:free] shard 1/16 (1 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-828Z-10301-1.log
[test:free] shard 2/16 (17 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-833Z-10301-2.log
[test:free] shard 3/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-834Z-10301-3.log
[test:free] shard 4/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-835Z-10301-4.log
[test:free] shard 5/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-836Z-10301-5.log
[test:free] shard 6/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-836Z-10301-6.log
[test:free] shard 7/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-837Z-10301-7.log
[test:free] shard 8/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-838Z-10301-8.log
[test:free] shard 9/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-838Z-10301-9.log
[test:free] shard 10/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-839Z-10301-10.log
[test:free] shard 11/16 (18 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-840Z-10301-11.log
[test:free] shard 12/16 (17 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-841Z-10301-12.log
[test:free] shard 13/16 (19 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-843Z-10301-13.log
[test:free] shard 14/16 (19 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-845Z-10301-14.log
[test:free] shard 15/16 (20 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-847Z-10301-15.log
[test:free] shard 16/16 (20 files)
[test:free] full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-855Z-10301-16.log
(pass) CSO evidence contracts > a crashed initializer claim and partial slot are recovered exactly [1.84ms]
Ran 1 tests across 1 files. [1ms]
Ran 3 tests across 1 files. [5ms]
Ran 1 tests across 1 files. [1ms]
Ran 1 test across 1 file. [30.00ms]
Ran 1 test across 1 file. [39.00ms]
Ran 5 tests across 1 files. [1ms]
Ran 5 tests across 1 files. [1ms]
Ran 1 tests across 1 files. [1ms]
(pass) test-free-shards: output contract (log capture, quiet console, failure epilogue) > crash markers surface in the epilogue as crashed+retried workers [8.49ms]
Ran 303 tests across 18 files. [29.25s]
[test:free] shard 7/16: 18 files, 29s, pass
[test:free] PASS — 303 tests, 18 files, 29s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-837Z-10301-7.log
Ran 412 tests across 18 files. [29.99s]
[test:free] shard 3/16: 18 files, 30s, pass
[test:free] PASS — 412 tests, 18 files, 30s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-834Z-10301-3.log
[train-gate] case-b: runner crashed
Ran 961 tests across 18 files. [30.49s]
[test:free] shard 10/16: 18 files, 31s, pass
[test:free] PASS — 961 tests, 18 files, 31s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-839Z-10301-10.log
Ran 581 tests across 18 files. [33.60s]
[test:free] shard 9/16: 18 files, 34s, pass
[test:free] PASS — 581 tests, 18 files, 34s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-838Z-10301-9.log
Ran 596 tests across 18 files. [33.79s]
[test:free] shard 5/16: 18 files, 34s, pass
[test:free] PASS — 596 tests, 18 files, 34s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-836Z-10301-5.log
Ran 635 tests across 18 files. [34.56s]
[test:free] shard 4/16: 18 files, 35s, pass
[test:free] PASS — 635 tests, 18 files, 35s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-835Z-10301-4.log
Ran 1163 tests across 17 files. [62.15s]
[test:free] shard 12/16: 17 files, 62s, pass
[test:free] PASS — 1163 tests, 17 files, 62s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-841Z-10301-12.log
Ran 529 tests across 18 files. [64.01s]
[test:free] shard 11/16: 18 files, 64s, pass
[test:free] PASS — 529 tests, 18 files, 64s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-840Z-10301-11.log
Ran 697 tests across 20 files. [68.93s]
[test:free] shard 15/16: 20 files, 69s, pass
[test:free] PASS — 697 tests, 20 files, 69s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-847Z-10301-15.log
Ran 877 tests across 18 files. [71.16s]
[test:free] shard 8/16: 18 files, 71s, pass
[test:free] PASS — 877 tests, 18 files, 71s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-838Z-10301-8.log
Ran 1120 tests across 18 files. [71.47s]
[test:free] shard 6/16: 18 files, 72s, pass
[test:free] PASS — 1120 tests, 18 files, 72s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-836Z-10301-6.log
Ran 539 tests across 17 files. [71.72s]
[test:free] shard 2/16: 17 files, 72s, pass
[test:free] PASS — 539 tests, 17 files, 72s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-833Z-10301-2.log
Ran 725 tests across 20 files. [74.45s]
[test:free] shard 16/16: 20 files, 74s, pass
[test:free] PASS — 725 tests, 20 files, 74s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-855Z-10301-16.log
Ran 778 tests across 19 files. [76.55s]
[test:free] shard 14/16: 19 files, 77s, pass
[test:free] PASS — 778 tests, 19 files, 77s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-845Z-10301-14.log
(pass) investigate-owned-ending-error: an earlier acknowledgment cannot cover a misleading final response [72.61ms]
(pass) investigate-owned-ending-error: the actual final response can span multiple native fragments [84.53ms]
(pass) captured owner questions from run 39195396438 > investigate-owned-ending-error: the captured question is answered once and the re-ask is refused [37.84ms]
Ran 199 tests across 19 files. [88.57s]
[test:free] shard 13/16: 19 files, 89s, pass
[test:free] PASS — 199 tests, 19 files, 89s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-843Z-10301-13.log
Ran 23 tests across 1 file. [114.92s]
[test:free] shard 1/16: 1 files, 115s, pass
[test:free] PASS — 23 tests, 1 files, 115s. Full log: /home/ubi/work/gstack/.context/free-test-logs/gstack-free-test-2026-10-26T01-49-46-828Z-10301-1.log
[test:free] FILES-FROM RESULT: files=275 tests=10138 failed=0 exit=0
PREGATE_VM_TREE_AFTER 3f4bbed38e3bcfd7c422bea43e637dc180a30ba1
ubi-runner: command exited 0 after 115s
ubi-runner: pull: nothing matches /tmp/gstack-free-test-*
ubi-runner: destroy requested: eu-central-h1/ubirun-<vm>
ubi-runner: destroyed eu-central-h1/ubirun-<vm>
+168 -2
View File
@@ -66,9 +66,175 @@ describe('gstack-gate-diff arguments and exit contract', () => {
test('an unresolvable base is exit 2 with GATE_ERROR=no_base and a concrete fetch as the ONLY stdout line', () => {
const r = run(repo({ fixture: 'rh1-skip' }), ['nosuchbranch']);
expect(r.status).toBe(2);
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin nosuchbranch']);
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin +nosuchbranch:refs/remotes/origin/nosuchbranch']);
const remote = run(repo({ fixture: 'rh1-skip' }), ['upstream/release']);
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin upstream/release');
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin +upstream/release:refs/remotes/origin/upstream/release');
// A relative, symbolic or full ref is not a refspec: it gets a plain fetch, never `+HEAD~9:refs/remotes/origin/HEAD~9`.
for (const ref of ['HEAD~9', 'FETCH_HEAD', 'refs/heads/nosuch']) {
expect([ref, run(repo({ fixture: 'rh1-skip' }), [ref]).stdout.trim()]).toEqual([ref, `GATE_ERROR=no_base ref=${ref} fix=git fetch origin`]);
}
// Nor does a relative ref get a remote-tracking twin: `feature~1` with no local `feature` stays no_base
// even though `origin/feature~1` would resolve (to a commit other than the one named).
const rooted = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-root-'));
repos.push(rooted);
gitIn(rooted, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${repo({ fixture: 'rh1-skip', commit: true })}`, '.']);
gitIn(rooted, ['checkout', '-q', '--orphan', 'solo']);
gitIn(rooted, ['commit', '-q', '--allow-empty', '-m', 'root']);
gitIn(rooted, ['branch', '-q', '-D', 'feature']);
expect(gitIn(rooted, ['rev-parse', '--verify', '-q', 'origin/feature~1^{commit}']).trim()).not.toBe('');
const rel = run(rooted, ['feature~1']);
expect(rel.status).toBe(2);
expect(rel.stdout.trim()).toBe('GATE_ERROR=no_base ref=feature~1 fix=git fetch origin');
});
test('a clone with no local main resolves the base to origin/main; the fix line it prints when that is missing too actually works', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-clone-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
const missing = run(dir, ['main']);
expect(missing.status).toBe(2);
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
// Value: protects=the fix line for a `<remote>/<branch>` base names the branch, not the tracking ref, in its refspec;
// fails_when=noBaseFix builds the refspec from the raw ref (`origin/main:refs/remotes/origin/origin/main`) or resolveBase
// reports a direct hit as a fallback; why_new=the only remote-form control uses a remote that does not exist, so it never
// reaches the known-remote branch; seam=none; no_claim=nothing about remotes other than origin.
expect(run(dir, ['origin/main']).stdout.trim()).toBe('GATE_ERROR=no_base ref=origin/main fix=git fetch origin +main:refs/remotes/origin/main');
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
const r = run(dir, ['main']);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is origin/main');
const direct = run(dir, ['origin/main']);
expect(direct.status).toBe(0);
expect(summary(direct.stdout).tagged).toBe('RH-1:1');
expect(direct.stderr).not.toContain('no local branch');
});
// Value: protects=a local base branch that shares no history with HEAD yields to its remote-tracking twin, and when
// the twin is missing too the printed fix repairs the next run instead of looping on the stale local branch;
// fails_when=resolveBase returns the first ref that resolves without checking it is related to HEAD, so the fix line's
// fetch into origin/main never changes what the next run picks; why_new=the other clone tests have no local main at
// all; seam=none; no_claim=nothing about a local branch that is related but behind.
test('a stale local main unrelated to HEAD yields to origin/main; without the twin the fix line repairs the next run', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-stale-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
gitIn(dir, ['checkout', '-q', '--orphan', 'main']);
gitIn(dir, ['rm', '-rfq', '.']);
fs.writeFileSync(path.join(dir, 'README'), 'unrelated history\n');
gitIn(dir, ['add', 'README']);
gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'unrelated main']);
gitIn(dir, ['checkout', '-q', 'feature']);
expect(spawnSync('git', ['merge-base', 'main', 'HEAD'], { cwd: dir, timeout: 30_000 }).status).not.toBe(0);
const stale = run(dir, ['main']);
expect(stale.status).toBe(2);
expect(stale.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
gitIn(dir, stale.stdout.trim().replace(/^.* fix=git /, '').split(' '));
const r = run(dir, ['main']);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: local main shares no history with HEAD; base is origin/main');
expect(r.stderr).not.toContain('no local branch');
// A ref name with shell metacharacters is quoted in the printed fix line (it is meant to be run verbatim).
const odd = run(dir, ['rel;echo pwned']);
expect(odd.status).toBe(2);
expect(odd.stdout.trim()).toBe("GATE_ERROR=no_base ref=rel;echo pwned fix=git fetch origin '+rel;echo pwned:refs/remotes/origin/rel;echo pwned'");
});
// Value: protects=a clone whose only remote is not called origin resolves the base through that remote and the fix line
// fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin'
// while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none;
// no_claim=nothing about the order when origin and another remote both carry the branch.
test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
expect(gitIn(dir, ['remote']).trim()).toBe('upstream');
const missing = run(dir, ['main']);
expect(missing.status).toBe(2);
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch upstream +main:refs/remotes/upstream/main');
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
const r = run(dir, ['main']);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is upstream/main');
});
// Value: protects=resolveBase walks past origin to the other remotes (origin exists but lacks the branch, upstream has it);
// fails_when=the loop stops after origin or only consults origin when origin exists; why_new=the upstream-only test has one
// remote and the origin tests have only origin, so the second iteration was never reached; seam=none; no_claim=nothing about
// which remote wins when both carry the branch.
test('origin without main and upstream with it: the base is upstream/main', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-two-remotes-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
gitIn(dir, ['remote', 'add', 'upstream', `file://${src}`]);
gitIn(dir, ['fetch', '-q', 'upstream', 'main:refs/remotes/upstream/main']);
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
const r = run(dir, ['main']);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is upstream/main');
});
// Value: protects=the default base (no argument) is found through the same remote list as resolveBase and the fix line, so an
// upstream-only clone of a master-default repository defaults to upstream/master instead of a `main` that exists nowhere;
// fails_when=defaultBase probes only `origin/<name>`; why_new=no test ran without a base in a clone whose remote is not
// origin; seam=the remote HEAD symref is removed first because single-branch clones set it on some git versions and not
// others; no_claim=nothing about a remote HEAD that points at the feature branch.
test('no explicit base in an upstream-only clone of a master-default repository: the base is upstream/master', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
gitIn(src, ['branch', '-m', 'main', 'master']);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-default-upstream-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
gitIn(dir, ['fetch', '-q', 'upstream', 'master:refs/remotes/upstream/master']);
gitIn(dir, ['remote', 'set-head', 'upstream', '-d']);
const r = run(dir, []);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: no local branch master; base is upstream/master');
});
// Value: protects=a shallow clone gets a fix line that unshallows into the tracking ref, both when the base is absent and
// when it is present but shares no reachable history (merge-base fails), and that line repairs the clone;
// fails_when=the shallow branch of noBaseFix keeps the bare `--unshallow origin main` (FETCH_HEAD only), or a resolved base
// with no merge-base stops being no_base; why_new=the existing shallow test covers only the `<sha>^` --deepen form, and no
// test reaches the merge-base-null branch; seam=none; no_claim=nothing about --deepen.
test('a shallow single-branch clone: the fix line unshallows into origin/main, including when origin/main exists but has no merge-base', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shallow-base-'));
repos.push(dir);
gitIn(dir, ['clone', '-q', '--depth', '1', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
const absent = run(dir, ['main']);
expect(absent.status).toBe(2);
expect(absent.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
gitIn(dir, ['fetch', '-q', '--depth', '1', 'origin', 'main:refs/remotes/origin/main']);
expect(gitIn(dir, ['rev-parse', '--verify', '-q', 'origin/main^{commit}']).trim()).not.toBe('');
const disjoint = run(dir, ['main']);
expect(disjoint.status).toBe(2);
expect(disjoint.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
gitIn(dir, disjoint.stdout.trim().replace(/^.* fix=git /, '').split(' '));
expect(gitIn(dir, ['rev-parse', '--is-shallow-repository']).trim()).toBe('false');
const r = run(dir, ['main']);
expect(r.status).toBe(0);
expect(summary(r.stdout).tagged).toBe('RH-1:1');
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is origin/main');
});
test('an empty candidate is exit 0 with a zero summary, never an error', () => {
+67
View File
@@ -16,6 +16,8 @@ function fakeEnv(opts: {
files?: Record<string, string>;
visibility?: string;
email?: string;
/** The repository root `git rev-parse --show-toplevel` reports; VERSION and CHANGELOG.md are read from `files` under it. */
root?: string;
respond?: (c: Call) => { status: number; stdout?: string; stderr?: string } | undefined;
} = {}) {
const calls: Call[] = [];
@@ -30,6 +32,7 @@ function fakeEnv(opts: {
if (custom) return { stdout: "", stderr: "", ...custom };
if (cmd === "git" && args[0] === "remote") return { status: 0, stdout: (opts.remote ?? "git@github.com:acme/widget.git") + "\n", stderr: "" };
if (cmd === "git" && args[0] === "config") return { status: opts.email ? 0 : 1, stdout: (opts.email ?? "") + "\n", stderr: "" };
if (cmd === "git" && args[0] === "rev-parse") return { status: opts.root ? 0 : 128, stdout: (opts.root ?? "") + "\n", stderr: opts.root ? "" : "fatal: not a git repository" };
if (cmd === "cfg") return { status: 0, stdout: (opts.visibility ?? "private") + "\n", stderr: "" };
if (cmd === "gh" && args[0] === "pr" && args[1] === "edit") {
const t = args.find(a => a.startsWith("--title="));
@@ -159,6 +162,70 @@ describe("gstack-post owns the redaction scan", () => {
}
});
test("the repo's own release versions in prose are not public IPs; other dotted quads still need confirmation", () => {
const repo = {
"/repo/VERSION": "1.91.226.0\n",
"/repo/CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n## [1.91.191.0] - 2026-10-24\n- x\n\n## v1.91.190.0\n\n## [1.128.1.0 mirror]\n",
};
const clean = [
"The receipt is written to a file (1.91.191.0).",
"Since v1.91.226.0 the queue refuses a READY without it.",
"1.91.191.0",
"landed in release 1.91.190.0 and 1.91.226.0",
];
for (const body of clean) {
const f = fakeEnv({ files: { ...repo, "/b": body + "\n" }, root: "/repo", visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
expect(f.posts()).toHaveLength(1);
}
const controls = [
"resolver 8.8.8.8 answered",
"egress is 203.0.113.7 in prod",
"the mirror at 1.128.1.0 is ours",
"host: 1.91.192.0",
];
for (const body of controls) {
const f = fakeEnv({ files: { ...repo, "/b": body + "\n" }, root: "/repo", visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
expect(f.stdout()).toContain("RULE: pii.ip_public LINE: 1 PART: body");
expect(f.posts()).toEqual([]);
}
const elsewhere = fakeEnv({ files: { "/b": "The receipt is written to a file (1.91.191.0).\n" }, visibility: "public" });
expect(run(["pr-comment", "12", "--body-file", "/b"], elsewhere)).toBe(EXIT.confirm);
expect(elsewhere.stdout()).toContain("RULE: pii.ip_public LINE: 1 PART: body");
const noVersion = fakeEnv({ files: { "/repo/VERSION": "2.4.0\n", "/b": "see (1.91.191.0)\n" }, root: "/repo", visibility: "public" });
expect(run(["pr-comment", "12", "--body-file", "/b"], noVersion)).toBe(EXIT.confirm);
});
test("a receipt's all-digit tree hash and GitHub run ids in prose or link text are not phone numbers; real numbers still are", () => {
const clean = [
"SHIP_RECEIPT: pr=3424 head=abc tree=704992822996",
"receipt tree 704992822996 verified",
"head=193687123456 gated_tree=704992822996 predecessor=123456789012 (gate-reuse: eligible)",
"cleared by run 19368712345",
"see [run 19368712345](https://github.com/garrytan/gstack/actions/runs/19368712345) for the gate",
"run #19368712345 is green",
];
for (const body of clean) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
expect(f.posts()).toHaveLength(1);
}
const controls = [
"call +14155551234 for the on-call",
"the id is 19368712345",
"run 4155551234 answered",
"tree=70499282299 is short",
"subtree=704992822996 is not a receipt key",
];
for (const body of controls) {
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
expect(f.stdout()).toContain("RULE: pii.phone.e164 LINE: 1 PART: body");
expect(f.posts()).toEqual([]);
}
});
test("the title is scanned too, and the pusher's own email is not a finding", () => {
const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } });
expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm);
+138 -1
View File
@@ -47,6 +47,25 @@ describe('pure pieces', () => {
expect(checkChangelog(r.text, '1.2.5.0')).toEqual([]);
expect(checkChangelog(r.text, '1.2.6.0').map(e => e.code)).toEqual(['CHANGELOG_TOP_MISMATCH']);
expect(checkChangelog(r.text + '\n## [1.2.3.0] - 2026-10-02\n\n- dup\n', '1.2.5.0').map(e => e.code)).toEqual(['CHANGELOG_DUPLICATE_HEADING']);
// A duplicate the base already carries is inherited history, not the PR's; one more copy still is.
const dupBase = '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n\n## [1.2.3.0] - 2026-10-02\n\n- dup\n';
const prText = '# Changelog\n\n## [1.2.5.0] - 2026-10-10\n\n- mine\n\n' + dupBase.slice('# Changelog\n\n'.length);
expect(checkChangelog(prText, '1.2.5.0')).toHaveLength(1);
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [dupBase])).toEqual([]);
const third = checkChangelog(prText + '\n## [1.2.3.0] - 2026-10-03\n\n- third\n', '1.2.5.0', 'CHANGELOG.md', [dupBase]);
expect(third.map(e => [e.code, e.path])).toEqual([['CHANGELOG_DUPLICATE_HEADING', 'CHANGELOG.md:15']]);
expect(third[0]!.message).toBe('1.2.3.0 headed 3 times (the base carries it 2 times; first at line 7)');
expect(checkChangelog(prText + '\n## [1.2.5.0] - 2026-10-11\n\n- again\n', '1.2.5.0', 'CHANGELOG.md', [dupBase]).map(e => e.message)).toEqual(['1.2.5.0 already headed at line 3']);
// Several readings of the base (its tip and the fork point) grant the larger count; an [Unreleased]
// placeholder the base keeps is inherited like any heading, and a second one is still the PR's.
const collapsed = '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base, merged copy\n';
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [collapsed, dupBase])).toEqual([]);
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [collapsed])).toHaveLength(1);
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [])).toHaveLength(1);
const placeholders = '# Changelog\n\n## [Unreleased]\n\n## [Unreleased]\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n';
expect(checkChangelog(placeholders, null, 'CHANGELOG.md', [placeholders])).toEqual([]);
expect(checkChangelog(placeholders, null)).toHaveLength(1);
expect(checkChangelog(placeholders + '\n## [Unreleased]\n\n- mine\n', null, 'CHANGELOG.md', [placeholders]).map(e => e.message)).toEqual(['Unreleased headed 3 times (the base carries it 2 times; first at line 3)']);
expect(reheadChangelog(r.text, { from: '1.2.5.0', to: '1.2.5.0', date: 'x' }).moved).toBe(false);
expect(reheadChangelog('# Changelog\n\n## [1.2.3.0] - d\n\n- base\n', { from: '9.9.9.9', to: '1.2.5.0', date: 'x' }).error?.code).toBe('CHANGELOG_SECTION_MISSING');
expect(normalizeChangelog(r.text)).toBe(normalizeChangelog(text.replace('[Unreleased]', '[1.2.9.0] - 2026-10-11')));
@@ -70,7 +89,13 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(a.status).toBe(EXIT.ok);
expect(a.out).toContain('ship, next in queue at 1.2.4.0');
expect(a.out).toContain('claims: ignored (stamp-at-merge)');
expect(a.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; .*CHANGELOG entry text only.*expected head [0-9a-f]{40}/m);
// Under a train the stamp commit may hold only restamp's own output: the next: line must not ask the lane
// to edit the CHANGELOG by hand (a lane that did was refused by the train's verify, freezing the queue).
// Value: protects=restamp's stamp-at-merge handoff never instructs a hand edit of CHANGELOG; fails_when=the
// claim-at-open wording ("write the CHANGELOG entry text") leaks into the train path; why_new=the earlier
// assertion pinned that wording as the contract; seam=none; no_claim=nothing about the lane obeying it.
expect(a.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; commit and push only the stamp diff above \(FILES\), with no edit of your own: .*expected head [0-9a-f]{40}/m);
expect(a.out).not.toMatch(/write the CHANGELOG|entry text|release summary/i);
expect(stages(a.out)).toMatchObject({ version: 'done', release_tool: 'done', changelog: 'done', stamp_paths: 'skipped', apply: 'done' });
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(readFile(repo, 'generated/digest.md')).toBe('# digest v1.2.4.0\nbody\n');
@@ -161,6 +186,10 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(r.out).not.toContain('ship, next in queue at');
const forced = restamp(repo, ['--next', '--dry-run', '--queue-mode', 'stamp-at-merge']);
expect(forced.out).toContain('claims: ignored (stamp-at-merge)');
// claim-at-open's bump still writes the entry text at /ship's CHANGELOG step; only the train path drops the ask.
const stamped = restamp(repo, ['--next']);
expect(stamped.status).toBe(EXIT.ok);
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
});
test('claim-at-open with an unknown claim says incomplete, names the PR and never says read', () => {
@@ -188,6 +217,114 @@ describe('three PRs, reordered twice, zero manual edits', () => {
});
});
describe('CHANGELOG duplicates and the base branch', () => {
const dupHistory = (repo: FixtureRepo) => {
const cl = readFile(repo, 'CHANGELOG.md');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl + '\n## [1.2.2.0] - 2026-09-02\n\n- older, headed a second time on main\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'history with a duplicate heading'); repo.git('push', '-q', 'origin', 'main');
};
test('a duplicate heading the base already carries is not the PR\'s: the stamp proceeds and keeps both copies', () => {
const repo = fixture();
dupHistory(repo);
expect(headings(repo)).toEqual(['1.2.3.0', '1.2.2.0', '1.2.2.0']);
makePr(repo, 'pr-a');
const r = restamp(repo, ['--next', '--pr', '1']);
expect(r.status).toBe(EXIT.ok);
expect(stages(r.out).changelog).toBe('done');
expect(headings(repo)).toEqual(['1.2.4.0', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
});
test('a duplicate heading the PR introduces is still CHANGELOG_DUPLICATE_HEADING with nothing written, base duplicates or not', () => {
const repo = fixture();
dupHistory(repo);
makePr(repo, 'pr-a');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), readFile(repo, 'CHANGELOG.md') + '\n## [1.2.2.0] - 2026-09-03\n\n- a third copy, added by the PR\n');
repo.git('commit', '-q', '-am', 'pr adds a duplicate');
const r = restamp(repo, ['--next', '--pr', '1']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/CHANGELOG\.md:\d+: 1\.2\.2\.0 headed 3 times \(the base carries it 2 times; first at line \d+\); nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
expect(headings(repo)).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0', '1.2.2.0', '1.2.2.0']);
const plain = fixture();
makePr(plain, 'pr-b');
fs.writeFileSync(path.join(plain.work, 'CHANGELOG.md'), readFile(plain, 'CHANGELOG.md') + '\n## [1.2.2.0] - 2026-09-03\n\n- second copy, added by the PR\n');
plain.git('commit', '-q', '-am', 'pr adds a duplicate');
const p = restamp(plain, ['--next', '--pr', '1']);
expect(p.status).toBe(EXIT.refused);
expect(p.err).toMatch(/1\.2\.2\.0 already headed at line \d+; nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
expect(readFile(plain, 'VERSION')).toBe('1.2.3.0\n');
});
// Value: protects=a PR forked while main headed a version twice still stamps after main collapsed the copies into
// one, because the allowance reads the fork point as well as the tip; fails_when=restamp reads only plan.baseSha;
// why_new=the other rows never move main after makePr; seam=none; no_claim=nothing about a duplicate main gained
// after the fork (the tip reading grants it, and the train merge dedups by heading).
test('a duplicate main collapsed after the fork is still inherited: the merge-base reading keeps the stamp going', () => {
const repo = fixture();
dupHistory(repo);
makePr(repo, 'pr-a');
repo.git('checkout', '-q', 'main');
const cl = readFile(repo, 'CHANGELOG.md').replace('\n## [1.2.2.0] - 2026-09-02\n\n- older, headed a second time on main\n', '');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl);
repo.git('commit', '-q', '-am', 'collapse the duplicate'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'pr-a');
expect(headings(repo)).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
const r = restamp(repo, ['--next', '--pr', '1']);
expect(r.status).toBe(EXIT.ok);
expect(headings(repo)).toEqual(['1.2.4.0', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
});
// Value: protects=a base with no CHANGELOG at all (the PR introduces the file) stamps, and the refusal for a
// duplicate the new file carries says the base reading was unavailable; fails_when=the git show failure is
// treated as an error, or the fallback to the self-contained rule is silent; why_new=every fixture seeds
// CHANGELOG.md on main; seam=none; no_claim=other git show failures are not distinguished from an absent file.
test('no CHANGELOG at the base: the PR\'s new file is checked against itself and the detail says so', () => {
const repo = fixture();
repo.git('rm', '-q', 'CHANGELOG.md'); repo.git('commit', '-q', '-m', 'no changelog yet'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'origin/main', '-b', 'pr-a');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- first entry\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'add changelog');
const r = restamp(repo, ['--next', '--pr', '1']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^stage changelog: done \(Unreleased -> 1\.2\.4\.0; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, so every duplicate heading counts\)$/m);
expect(headings(repo)).toEqual(['1.2.4.0']);
const dup = fixture();
dup.git('rm', '-q', 'CHANGELOG.md'); dup.git('commit', '-q', '-m', 'no changelog yet'); dup.git('push', '-q', 'origin', 'main');
dup.git('checkout', '-q', 'origin/main', '-b', 'pr-b');
fs.writeFileSync(path.join(dup.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- first entry\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
dup.git('add', '-A'); dup.git('commit', '-q', '-m', 'add changelog with a duplicate');
const d = restamp(dup, ['--next', '--pr', '1']);
expect(d.status).toBe(EXIT.refused);
expect(d.err).toMatch(/0\.9\.0\.0 already headed at line \d+; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, so every duplicate heading counts; nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
expect(fs.existsSync(path.join(dup.work, 'VERSION')) && readFile(dup, 'VERSION')).toBe('1.2.3.0\n');
});
// Value: protects=when only one base reading exists (main gained the CHANGELOG after the fork), inherited
// headings are still counted from it and the detail names the reading that was used; fails_when=the note
// claims every duplicate counts while the readable reading granted an allowance, or the unreadable reading
// is treated as an error; why_new=the other rows read the same sha twice; seam=none; no_claim=nothing about
// a file renamed between the two readings.
test('one readable base reading: inherited headings count from it and the detail says so', () => {
const repo = fixture();
repo.git('rm', '-q', 'CHANGELOG.md'); repo.git('commit', '-q', '-m', 'no changelog yet'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'origin/main', '-b', 'pr-a');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- mine\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'pr adds the changelog, carrying history with a duplicate');
repo.git('checkout', '-q', 'main');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'main adds the changelog too'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'pr-a');
const r = restamp(repo, ['--next', '--pr', '1']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^stage changelog: done \(Unreleased -> 1\.2\.4\.0; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, inherited headings counted at [0-9a-f]{12} only\)$/m);
expect(headings(repo)).toEqual(['1.2.4.0', '0.9.0.0', '0.9.0.0']);
});
});
describe('journal and injected failures', () => {
for (const stage of ['version', 'release_tool', 'changelog', 'stamp_paths']) {
test(`a failure after the ${stage} stage leaves the branch untouched and the staging worktree removed`, () => {
+3
View File
@@ -127,6 +127,9 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/eng-test-plan-edit-cli.js': ['test/eng-test-plan-edit-approval.test.ts'],
'test/fixtures/eng-test-plan-edit-dacc.json': ['test/eng-test-plan-edit-approval.test.ts'],
'test/fixtures/eval-history/wave-reds-8-censuses.json': ['test/eval-history.test.ts'],
'test/fixtures/evidence/pregate-remote-cleared.out': ['test/evidence.test.ts'],
'test/fixtures/evidence/pregate-remote-failed.out': ['test/evidence.test.ts'],
'test/fixtures/evidence/shard-runner-files-from.out': ['test/evidence.test.ts'],
'test/fixtures/exercised-map-replay.json': ['scripts/exercised-map.ts'],
'test/fixtures/exit-propagation/**': ['test/exit-propagation.test.ts'],
'test/fixtures/free-shard-capture.ts': ['test/test-free-shards-capture.test.ts'],