mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
feat(pregate): clear --receipt accepts a remote_gate receipt from a stamp-only predecessor tree (gbrain gate-ahead)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8663c3d25f
commit
3732bbb3bf
9 files changed
+455
-20
No files matched your search
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**A remote gate that ran ahead of the stamp now clears /ship's small-machine obligation; gbrain stops paying a second full gate per PR.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt <file>` accepts a `PREGATE_REMOTE:` receipt whose tree differs from pregate.json's tree only in the repo's declared stamp paths. gstack proves it itself: a `git diff` of the two trees, every differing file classified by the tree receipt's own rule (`lib/tree-receipt.ts` `classifyFile` under `.gstack/ship-policy.json` on the base branch: `stamp_files`, `stamp_patterns`, and the built-in version-file, manifest-version, lockfile-root-version and changelog-heading forms), so the pregate verdict and `gstack-tree-receipt`'s can never disagree; there is no second stamp list. gbrain's rule lets a head reuse a full gate whose tree differs only in release stamps, but /ship's Step 12 restamp always changed those files, so no gate-ahead run could satisfy `remote_gate` and every gbrain PR ran the whole gate again after /ship (#7621, #7674, #7681: 17–37 min, 64–128 vCPU each).
|
||||
|
||||
### Added
|
||||
- **`lib/pregate/remote-receipt.ts`** (`substituteStampOnlyTree`): both trees must be objects here (the gated commit fetched, the head tree `bin/gstack-wtree` wrote); a path outside the rule, a stamp path whose hunk is not the exact old → new rewrite, an unfetched gated tree, an unreadable base policy or equal versions refuse with `PREGATE_RECEIPT_INVALID` and a fix naming the offending path (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`). The tests, failed and exit checks on the receipt are unchanged.
|
||||
- **The substitution is recorded.** The receipt string gains `accepted-for=<head> stamp-only=<n>` after `tests=`, and pregate.json's obligation carries `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`; `clear` prints `PREGATE_RECEIPT_SUBSTITUTED: gated tree … accepted for tree … under <policy>; stamp-only: <path> (<rule>), …`, `verify` repeats it on its ok line, and a substitution whose `accepted_for` is not pregate.json's tree is `PREGATE_REMOTE_UNCLEARED`.
|
||||
- **Docs:** [docs/pregate.md#remote-gate-stamp-only](docs/pregate.md#remote-gate-stamp-only) (what is accepted, what is refused, how a consuming repo declares its stamp paths: `.gstack/ship-policy.json` on the base branch, the file the tree receipt already reads, and the four-command sequence that reuses a gate-ahead run); the `PREGATE_RECEIPT_INVALID` and `PREGATE_REMOTE_UNCLEARED` troubleshooting anchors.
|
||||
|
||||
### Changed
|
||||
- `lib/tree-receipt.ts` exports `classifyFile` (the function is unchanged) so pregate reuses the rule instead of restating it; `PregateRemote` in `lib/headless-artifacts.ts` gains the optional `substitution` field and its schema entry.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-remote-stamp-only.test.ts`: accept (five stamp files under a `stamp_files` + `stamp_patterns` policy, the recorded substitution equal to `gstack-tree-receipt`'s file verdicts on the same pair, `verify` ok), the unchanged same-tree path, refusals (a path outside the rule, a stamp path with a description edit, an unfetched gated tree, an unreadable receipt file, zero tests, a failure), `verify` on a misapplied substitution, and `clearDecision` without a repository root.
|
||||
|
||||
## [1.91.252.0] - 2026-10-26
|
||||
|
||||
**`/retro` can now say whether the factory is getting better, and a new team has one page that takes it from zero to its first merged train on gstack + Capy.** `bin/gstack-factory-scorecard` reads one window (default 7 days) from GitHub through `gh` and prints a Markdown table plus a CSV: merged PRs (crew vs outside contributors), lead time (PR created → merged, and first READY comment → merged), gate runs green / red / cancelled with their known cost, cost per merge, the red-train rate, flaky events from the same two sources the daily flaky pass reads, issues opened vs closed, and releases cut. Each line carries its counting rule and what one of the best software factories would show on it; a line whose input cannot be read says `unavailable (<reason>)`, never 0. `docs/recommended-harness.md` is the adoption guide: install, the Captain and its Drive, the first train, the daily flaky pass, the weekly release, the scorecard with gstack's own numbers against the bar, what is Capy-specific, the options where two crews differ, and the known Capy limits as of 2026-10-26.
|
||||
|
||||
+8
-4
@@ -33,6 +33,7 @@ import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
import { ensureTmpExcluded } from '../lib/restamp';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
@@ -48,7 +49,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
run (default) stage 1 mechanical preflight (${CHECKS.filter(c => c.stage === 'preflight').map(c => c.id).join(', ')}), then stage 2 test execution (${CHECKS.filter(c => c.stage === 'tests').map(c => c.id).join(', ')})
|
||||
verify publication check against ${PREGATE_OUT_REL}: current tree, no fail or incomplete, every requires-remote obligation cleared
|
||||
clear record the receipt that clears one requires-remote obligation: pregate.json/remote_gate needs
|
||||
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree); a
|
||||
--receipt <file> (the gate's PREGATE_REMOTE: line, checked against the tested tree; a tree that
|
||||
differs from pregate.json's only in the base branch's stamp rule is accepted and recorded as a
|
||||
substitution, docs/pregate.md#remote-gate-stamp-only); a
|
||||
test_backend/<backend> run URL is recorded as attested, not validated
|
||||
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
|
||||
validate | init the working-tree ${PREGATE_CONFIG_PATH}
|
||||
@@ -209,14 +212,15 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const out = outPath(root, rest);
|
||||
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
|
||||
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
|
||||
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') });
|
||||
const decision = clearDecision(read.file, lane, { runUrl: flag(rest, '--run-url'), bundleLane: flag(rest, '--bundle-lane'), receiptPath: flag(rest, '--receipt') }, root);
|
||||
if ('refused' in decision) {
|
||||
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
|
||||
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
|
||||
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${decision.fix ?? RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
|
||||
return EXIT.fail;
|
||||
}
|
||||
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`);
|
||||
const file = clearObligation(read.file, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
|
||||
writePregateFile(root, out, file);
|
||||
if (decision.substitution) process.stdout.write(`PREGATE_RECEIPT_SUBSTITUTED: ${describeSubstitution(decision.substitution)}\n`);
|
||||
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
+64
-1
@@ -333,7 +333,9 @@ the supervision above. Close the obligation one of two ways:
|
||||
|
||||
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
|
||||
--receipt <file>` with the gate's output, validated as `--remote` validates it
|
||||
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt), or
|
||||
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt; a receipt
|
||||
for a tree that differs from this one only by the repo's stamp rule is
|
||||
accepted and recorded, see [below](#remote-gate-stamp-only)), or
|
||||
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
|
||||
has no machine-checkable receipt and is recorded as `attested:<url>`; or
|
||||
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
|
||||
@@ -350,6 +352,67 @@ the supervision above. Close the obligation one of two ways:
|
||||
`spent, reserved, unknown`: a gate that prints no `spent=` is `unknown`,
|
||||
counted at the cap.
|
||||
|
||||
<a id="remote-gate-stamp-only"></a>
|
||||
### A receipt from a stamp-only predecessor tree (gate ahead of the stamp)
|
||||
|
||||
The receipt `clear --receipt` validates names the tree the gate tested, and
|
||||
pregate.json names the tree being published. A repo whose full gate runs before
|
||||
its turn in the queue ("gate ahead": the gate tests commit G, then /ship's
|
||||
restamp moves the version files, so the head H it publishes is G plus the
|
||||
stamp) could never clear the obligation with that run, and paid a second full
|
||||
gate on H for a diff that was nothing but stamps. `clear` now accepts that
|
||||
receipt for H when the two trees differ only as a stamp does:
|
||||
|
||||
- gstack diffs the gated tree against pregate.json's tree
|
||||
(`git diff --unified=0 <gated> <head>`, both trees as objects in this
|
||||
repository: the gated commit's tree once it is fetched, and the head tree
|
||||
`bin/gstack-wtree` wrote) and classifies every differing file with the tree
|
||||
receipt's own rule, `lib/tree-receipt.ts` under `.gstack/ship-policy.json` on
|
||||
`origin/<base>` ([ship-policy](ship-policy.md#tree-receipt)). That is the rule
|
||||
`gstack-tree-receipt`, `gstack-pregate regate` and the train's
|
||||
`verify --train` apply: with `stamp_files` set only those files can be
|
||||
stamps, each hunk in them must be the exact old → new rewrite under
|
||||
`stamp_patterns` (literal `{version}` templates) or the built-in forms
|
||||
(`(was X)`, `since: X`, `vX`), and the version file, `package.json`'s
|
||||
`version`, a lockfile's root version keys and a CHANGELOG re-heading have
|
||||
their own built-in rules. There is no second list and no looser rule here:
|
||||
the verdict `clear` reaches on a pair is the verdict `gstack-tree-receipt
|
||||
--gated G --current H` reaches.
|
||||
- Accepted, the obligation's receipt reads `remote-gate tree=<gated>
|
||||
tests=<n> accepted-for=<head> stamp-only=<files> receipt=<file>
|
||||
sha256=<digest>`, and pregate.json records the substitution beside it:
|
||||
`substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`,
|
||||
the policy label (`origin/<base>@<sha>:.gstack/ship-policy.json`) and every
|
||||
differing path with the rule that classed it (`version-file`,
|
||||
`manifest-version`, `lockfile-root-version`, `changelog-heading`,
|
||||
`stamp_files`, `stamp_paths`, `release_outputs`). `clear` prints the same
|
||||
as `PREGATE_RECEIPT_SUBSTITUTED: …`, `verify` repeats it on its ok line, and
|
||||
the ship receipt carries the receipt string. The gate's own tests, failed
|
||||
and exit checks still apply to the receipt as before.
|
||||
- Refused, with `PREGATE_RECEIPT_INVALID` and a fix naming the offending
|
||||
path: any differing path outside the rule (`lib/x.ts (stamp_files: not in the
|
||||
repo's stamp_files allowlist, line 1)`), a stamp path whose hunk is not the
|
||||
stamp rewrite (`plugin.json (stamp_files: "…description…" -> "…", line 4)`),
|
||||
a gated tree that is not an object here (fetch the gated commit first), a
|
||||
base branch whose policy cannot be read, or two trees at the same version
|
||||
(a `stamp_files` hunk needs an old → new version to rewrite). The obligation stays uncleared; rerun
|
||||
the gate on the head, or make the extra change a stamp under the repo's
|
||||
rule.
|
||||
|
||||
A consuming repo declares its stamp paths once, in `.gstack/ship-policy.json`
|
||||
on its base branch (`stamp_files`, `stamp_patterns`, `changelog`), the same
|
||||
file the tree receipt and restamp already read; nothing is declared in
|
||||
`.gstack/pregate.json` for this. gbrain's is in
|
||||
[gbrain worked configs](#gbrain-worked-configs). The sequence that reuses a
|
||||
gate-ahead run, on a machine under 8 cores:
|
||||
|
||||
```bash
|
||||
git fetch origin <gated sha> # the gated commit's tree must be here
|
||||
gstack-pregate --allow-repo-commands # on the stamped head: records pregate.json/remote_gate uncleared
|
||||
gstack-pregate clear --remote pregate.json/remote_gate --receipt <the gate's output for the gated tree>
|
||||
gstack-pregate verify # PREGATE_VERIFY: ok …; pregate.json/remote_gate: gated tree … accepted for tree …
|
||||
```
|
||||
|
||||
gstack's own example: `.gstack/pregate.json` declares
|
||||
`"remote_gate": "bash scripts/ubicloud/pregate-remote.sh"`. With
|
||||
`UBICLOUD_API_KEY` set the script runs the selected files on one Ubicloud VM
|
||||
|
||||
+26
-4
@@ -3106,9 +3106,24 @@ reports failures. The obligation stays uncleared. A run URL alone never clears
|
||||
this obligation; a `test_backend/<backend>` obligation takes a run URL, recorded
|
||||
as attested rather than validated.
|
||||
|
||||
Another tree is accepted in exactly one case: it differs from `pregate.json`'s
|
||||
tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base
|
||||
branch: `stamp_files`, `stamp_patterns`, the built-in version, manifest,
|
||||
lockfile and CHANGELOG forms), judged by the tree receipt's own classifier on a
|
||||
`git diff` of the two trees ([the gate ran ahead of the
|
||||
stamp](pregate.md#remote-gate-stamp-only)). The refusal line then says `and the
|
||||
trees differ beyond the stamp rule of <policy>: <path> (<rule>: <why>, line
|
||||
<n>)`, and the path it names is the one that is not a stamp: a file outside
|
||||
`stamp_files`, or a stamp file with a hunk that is not the old → new rewrite.
|
||||
`the gated tree <t> is not an object in this repository` means the gated
|
||||
commit is not fetched here, so the two trees cannot be compared.
|
||||
|
||||
**Fix.** Run the remote gate on the tree pregate tested and pass its output
|
||||
file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
|
||||
<usd> --allow-repo-commands` run and validate it.
|
||||
<usd> --allow-repo-commands` run and validate it. For a gate-ahead receipt:
|
||||
fetch the gated commit (`git fetch origin <sha>`) and clear again, or make the
|
||||
named path a stamp under the repo's rule; a receipt is never accepted while any
|
||||
differing path is a change.
|
||||
|
||||
<a id="pregate-stale"></a>
|
||||
### `PREGATE_STALE`
|
||||
@@ -3126,12 +3141,19 @@ refuses to write the ship receipt otherwise.
|
||||
### `PREGATE_REMOTE_UNCLEARED`
|
||||
|
||||
**Meaning.** A `requires-remote` obligation (a platform lane this machine cannot
|
||||
run, named by `workflow.yml/job`) has no receipt.
|
||||
run, named by `workflow.yml/job`, or the small-machine `pregate.json/remote_gate`
|
||||
tier) has no receipt; or a `remote_gate` receipt accepted from a stamp-only
|
||||
predecessor tree records `accepted_for` as a tree other than `pregate.json`'s
|
||||
(the file was edited after `clear`), so it clears nothing here.
|
||||
|
||||
**Fix.** Dispatch that job, then
|
||||
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>` (or
|
||||
`--bundle-lane <label>` from an evidence bundle). The ship receipt carries each
|
||||
obligation with its receipt.
|
||||
`--bundle-lane <label>` from an evidence bundle). For `pregate.json/remote_gate`,
|
||||
`clear --remote pregate.json/remote_gate --receipt <gate output>`: the gate's
|
||||
own run on this tree, or a run on a predecessor tree that differs only by the
|
||||
repo's stamp rule ([gate ahead of the stamp](pregate.md#remote-gate-stamp-only));
|
||||
`verify` then prints the accepted substitution on its ok line. The ship receipt
|
||||
carries each obligation with its receipt.
|
||||
|
||||
<a id="issue-unreadable"></a>
|
||||
### `ISSUE_UNREADABLE`
|
||||
|
||||
@@ -68,7 +68,9 @@ export interface PregateCheck {
|
||||
// C-pregate (C8): stage, the input identity the check ran against, the fix clause and result code, and per-item lines.
|
||||
stage?: 'preflight' | 'tests'; inputs?: Record<string, string>; fix?: string; code?: string; lines?: string[];
|
||||
}
|
||||
export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string }
|
||||
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
|
||||
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
|
||||
export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string; substitution?: PregateReceiptSubstitution }
|
||||
export interface PregateFile {
|
||||
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
|
||||
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
|
||||
@@ -194,7 +196,8 @@ export const ARTIFACT_SCHEMAS = {
|
||||
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } },
|
||||
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str,
|
||||
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
|
||||
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str,
|
||||
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
|
||||
},
|
||||
},
|
||||
'ship-receipt': {
|
||||
|
||||
+27
-8
@@ -24,6 +24,8 @@ import { straysCheck } from './checks/strays';
|
||||
import { timingCheck } from './checks/timing';
|
||||
import { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
|
||||
import { sha256, withFingerprint } from './context';
|
||||
import { baseBranchOf, describeSubstitution, substituteStampOnlyTree, type StampOnlySubstitution } from './remote-receipt';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
|
||||
|
||||
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
|
||||
@@ -138,7 +140,11 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
|
||||
const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed');
|
||||
if (incomplete.length) return { ok: false, code: 'PREGATE_INCOMPLETE', message: `${incomplete.length} check(s) incomplete: ${incomplete.map(c => c.id).join(', ')}`, current, uncleared };
|
||||
if (uncleared.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${uncleared.length} requires-remote obligation(s) without a receipt: ${uncleared.map(r => r.lane).join(', ')}`, current, uncleared };
|
||||
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
|
||||
// A receipt accepted from a stamp-only predecessor tree was accepted for one head tree; recorded for another, it clears nothing here.
|
||||
const misapplied = (file.requires_remote ?? []).filter(r => r.substitution && r.substitution.accepted_for !== file.tree);
|
||||
if (misapplied.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${misapplied.length} remote receipt(s) accepted for another tree: ${misapplied.map(r => `${r.lane} (accepted for ${r.substitution!.accepted_for.slice(0, 12)}, pregate.json is ${file.tree.slice(0, 12)})`).join(', ')}; clear again`, current, uncleared: misapplied };
|
||||
const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`);
|
||||
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}`, current, uncleared };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,17 +169,21 @@ function userPolicyChange(file: PregateFile): string | null {
|
||||
}
|
||||
|
||||
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
|
||||
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
|
||||
export type ClearDecision = { receipt: string; attested: boolean; substitution?: StampOnlySubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
|
||||
|
||||
/**
|
||||
* What `clear` may record for one obligation. A `pregate.json/remote_gate`
|
||||
* obligation needs the gate's own report (`--receipt <file>` holding its
|
||||
* PREGATE_REMOTE: line), validated as `--remote` validates it against the
|
||||
* tested tree; a run URL is not checkable there. A `test_backend/<backend>`
|
||||
* tested tree; a run URL is not checkable there. With `repoRoot`, a report
|
||||
* whose tree is a stamp-only predecessor of pregate.json's tree (the gate ran
|
||||
* ahead of the stamp; lib/pregate/remote-receipt.ts) is accepted for this
|
||||
* tree and the substitution recorded; without it, or when any differing path
|
||||
* is not a stamp, the tree mismatch refuses as before. A `test_backend/<backend>`
|
||||
* obligation has no machine-checkable receipt, so its run URL is recorded as
|
||||
* attested. Platform lanes take a run URL or an evidence-bundle lane label.
|
||||
*/
|
||||
export function clearDecision(file: PregateFile, lane: string, input: ClearInput): ClearDecision {
|
||||
export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot?: string): ClearDecision {
|
||||
const list = file.requires_remote ?? [];
|
||||
const target = list.find(r => r.lane === lane);
|
||||
if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` };
|
||||
@@ -183,9 +193,17 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
|
||||
try { text = fs.readFileSync(input.receiptPath, 'utf8'); }
|
||||
catch (e: any) { return { refused: 'PREGATE_RECEIPT_INVALID', message: `cannot read ${input.receiptPath}: ${e.message}` }; }
|
||||
const report = parseRemoteReport(text);
|
||||
const problem = report ? remoteReportProblem(report, file.tree) : `${input.receiptPath} has no PREGATE_REMOTE: line`;
|
||||
if (!report) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${input.receiptPath} has no PREGATE_REMOTE: line; ${lane} stays uncleared` };
|
||||
let substitution: StampOnlySubstitution | undefined;
|
||||
if (report.tree !== file.tree && repoRoot) {
|
||||
const s = substituteStampOnlyTree(repoRoot, baseBranchOf(file.base) ?? resolveBaseBranch(repoRoot), report.tree, file.tree);
|
||||
if ('problem' in s) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${s.problem}; ${lane} stays uncleared`, fix: s.fix };
|
||||
substitution = s.substitution;
|
||||
}
|
||||
const problem = remoteReportProblem(report, substitution ? report.tree : file.tree);
|
||||
if (problem) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${problem}; ${lane} stays uncleared` };
|
||||
return { receipt: `remote-gate tree=${file.tree.slice(0, 12)} tests=${report!.tests} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false };
|
||||
const accepted = substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${substitution.files.length}` : '';
|
||||
return { receipt: `remote-gate tree=${report.tree.slice(0, 12)} tests=${report.tests}${accepted} receipt=${path.resolve(input.receiptPath)} sha256=${createHash('sha256').update(text).digest('hex').slice(0, 12)}`, attested: false, substitution };
|
||||
}
|
||||
if (input.receiptPath) return { refused: 'usage', message: `--receipt applies only to ${REMOTE_GATE_LANE}; clear ${lane} with --run-url <url>${target.workflow === 'test_backend' ? '' : ' or --bundle-lane <label>'}` };
|
||||
if (target.workflow === 'test_backend') {
|
||||
@@ -197,12 +215,13 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
|
||||
return { receipt, attested: false };
|
||||
}
|
||||
|
||||
/** Record the receipt that clears one obligation (a run URL or an evidence-bundle lane label). */
|
||||
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string): PregateFile {
|
||||
/** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */
|
||||
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: StampOnlySubstitution): PregateFile {
|
||||
const list = file.requires_remote ?? [];
|
||||
const target = list.find(r => r.lane === lane);
|
||||
if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`);
|
||||
target.receipt = receipt; target.cleared_by = by;
|
||||
if (substitution) target.substitution = substitution; else delete target.substitution;
|
||||
file.verdict = verdictOf(file);
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* pregate remote receipt — a remote_gate receipt from a predecessor tree.
|
||||
*
|
||||
* The `pregate.json/remote_gate` obligation clears on the gate's own
|
||||
* `PREGATE_REMOTE: tree=… tests=… failed=…` line, and that tree had to be the
|
||||
* tree pregate.json recorded. A repo whose full gate ran ahead of the ship
|
||||
* ("gate ahead of your turn": the gate tested commit G, then /ship's restamp
|
||||
* moved the version files) could never clear the obligation with that run:
|
||||
* the stamp changed the tree, so every PR paid a second full gate on the
|
||||
* stamped head (gbrain #7621, #7674, #7681: 17–37 minutes, 64–128 vCPU each).
|
||||
*
|
||||
* `substituteStampOnlyTree` is the one accepted substitution. The gated tree
|
||||
* and the head tree are diffed path by path, and every differing file is
|
||||
* classified by the tree receipt's own rule (lib/tree-receipt.ts
|
||||
* `classifyFile`: the repo's `stamp_files` allowlist and `stamp_patterns`
|
||||
* templates from `.gstack/ship-policy.json` on the base branch, beside the
|
||||
* built-in version-file, manifest-version, lockfile-root-version and
|
||||
* changelog-heading forms). The receipt is accepted for the head only when
|
||||
* every differing file is a stamp; a path outside the rule, or a stamp path
|
||||
* whose hunk is not the exact old → new rewrite, refuses it with the path
|
||||
* named. There is no second stamp list here: a verdict this module reaches is
|
||||
* the verdict `gstack-tree-receipt` reaches on the same pair.
|
||||
*
|
||||
* Both trees must be objects in this repository (the gated tree is the
|
||||
* gated commit's tree once that commit is fetched; the head tree is the
|
||||
* working-tree fingerprint `bin/gstack-wtree` wrote). A tree that is not
|
||||
* here cannot be diffed and is refused, never trusted.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { parseUnifiedDiff, versionAtRef } from '../restamp';
|
||||
import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy';
|
||||
import { classifyFile } from '../tree-receipt';
|
||||
|
||||
/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head. */
|
||||
export interface StampOnlySubstitution {
|
||||
/** The tree the remote gate tested (its PREGATE_REMOTE line). */
|
||||
gated_tree: string;
|
||||
/** The tree pregate.json recorded, which the receipt is accepted for. */
|
||||
accepted_for: string;
|
||||
/** The policy that classed the differing files: `origin/<base>@<sha>:.gstack/ship-policy.json`, or the defaults when the base has none. */
|
||||
policy: string;
|
||||
/** Every path that differs between the two trees, with the tree receipt rule that classed it as a stamp. */
|
||||
files: Array<{ path: string; rule: string }>;
|
||||
}
|
||||
|
||||
export type SubstitutionResult = { substitution: StampOnlySubstitution } | { problem: string; fix: string };
|
||||
|
||||
function git(cwd: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return r.status === 0 ? (r.stdout ?? '').replace(/\n$/, '') : null;
|
||||
}
|
||||
|
||||
/** The branch a pregate.json `base` field (`origin/<branch>@<sha>`) names; null when the field is absent or unreadable. */
|
||||
export function baseBranchOf(base: string | undefined): string | null {
|
||||
const m = /^(?:origin\/)?(.+?)@[0-9a-f]+$/.exec(base ?? '');
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `gatedTree` differs from `headTree` only as a stamp does, under the
|
||||
* base branch's ship policy. Returns the substitution to record, or the
|
||||
* problem (the first clause is the plain tree mismatch, so a caller's message
|
||||
* reads the same as before) with the fix.
|
||||
*/
|
||||
export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTree: string, headTree: string): SubstitutionResult {
|
||||
const short = (t: string) => t.slice(0, 12);
|
||||
const mismatch = `the remote gate tested tree ${short(gatedTree)}, not ${short(headTree)}`;
|
||||
const rerun = `rerun the remote gate on tree ${short(headTree)} and pass its output with --receipt, or run gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`;
|
||||
for (const [label, tree] of [['gated', gatedTree], ['head', headTree]] as const) {
|
||||
if (git(repoRoot, ['cat-file', '-t', tree]) !== 'tree') {
|
||||
return { problem: `${mismatch}; the ${label} tree ${short(tree)} is not an object in this repository, so the two trees cannot be compared`, fix: label === 'gated' ? `fetch the commit the remote gate tested (git fetch origin <its sha>) so its tree is here, then clear again; or ${rerun}` : rerun };
|
||||
}
|
||||
}
|
||||
const loaded = loadPolicyFromBase(repoRoot, base);
|
||||
if (isUnavailable(loaded)) return { problem: `${mismatch}; the ship policy on origin/${loaded.base} could not be read (${loaded.detail}), so the stamp rule is unknown`, fix: `git fetch origin ${loaded.base}, then clear again; or ${rerun}` };
|
||||
if (loaded.errors.length) return { problem: `${mismatch}; ${SHIP_POLICY_PATH} on ${loaded.source.label} is invalid (${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}), so the stamp rule is unknown`, fix: `fix the policy on the base branch (gstack-ship-policy validate), then clear again; or ${rerun}` };
|
||||
const gv = versionAtRef(repoRoot, gatedTree);
|
||||
const cv = versionAtRef(repoRoot, headTree);
|
||||
const versionRel = 'rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION';
|
||||
const oldV = 'version' in gv ? gv.version : null;
|
||||
const newV = 'version' in cv ? cv.version : null;
|
||||
const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedTree, headTree]);
|
||||
if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
|
||||
const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV));
|
||||
if (!files.length) return { problem: `${mismatch}; the trees differ but git diff lists no file (a mode-only or submodule change)`, fix: rerun };
|
||||
const changed = files.filter(f => f.verdict === 'changed');
|
||||
if (changed.length) {
|
||||
const named = changed.map(c => `${c.path} (${c.rule}${c.residual ? `: ${c.residual}` : ''}${c.line ? `, line ${c.line}` : ''})`).join('; ');
|
||||
return { problem: `${mismatch}, and the trees differ beyond the stamp rule of ${loaded.source.label}: ${named}`, fix: `${rerun}; a receipt from a predecessor tree is accepted only when every differing path is a stamp (stamp_files, stamp_patterns and the built-in version, manifest, lockfile and changelog forms; see docs/pregate.md#remote-gate-stamp-only)` };
|
||||
}
|
||||
return { substitution: { gated_tree: gatedTree, accepted_for: headTree, policy: loaded.source.label, files: files.map(f => ({ path: f.path, rule: f.rule })) } };
|
||||
}
|
||||
|
||||
/** One line naming the substitution, for `clear`'s output and `verify`'s ok line. */
|
||||
export function describeSubstitution(s: StampOnlySubstitution): string {
|
||||
return `gated tree ${s.gated_tree.slice(0, 12)} accepted for tree ${s.accepted_for.slice(0, 12)} under ${s.policy}; stamp-only: ${s.files.map(f => `${f.path} (${f.rule})`).join(', ')}`;
|
||||
}
|
||||
+1
-1
@@ -68,7 +68,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string
|
||||
return normalizeLockfile(name, a) === normalizeLockfile(name, b);
|
||||
}
|
||||
|
||||
function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict {
|
||||
export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict {
|
||||
const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line });
|
||||
const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' });
|
||||
if (f.binary) return changed('binary', 'binary content differs');
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* GSTA-117 (gbrain's gate-ahead ask): `gstack-pregate clear --remote
|
||||
* pregate.json/remote_gate --receipt <file>` accepts a remote gate report
|
||||
* whose tree differs from pregate.json's tree only in the repo's declared
|
||||
* stamp paths, classified by the tree receipt's own rule
|
||||
* (lib/tree-receipt.ts classifyFile under .gstack/ship-policy.json on the
|
||||
* base branch), and records the substitution: the gated tree, the tree it
|
||||
* was accepted for, the policy, and every differing path with its rule. A
|
||||
* non-stamp path, a stamp path with a non-stamp hunk, a gated tree that is
|
||||
* not in the object store and an unreadable receipt file still refuse with
|
||||
* PREGATE_RECEIPT_INVALID and a fix naming the offending path.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { buildContext } from '../lib/pregate/context';
|
||||
import { clearDecision, runPregate, verifyPregate } from '../lib/pregate/index';
|
||||
import { baseBranchOf, substituteStampOnlyTree } from '../lib/pregate/remote-receipt';
|
||||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const GATE = 'node scripts/remote-gate.js';
|
||||
const LANE = 'pregate.json/remote_gate';
|
||||
const PREGATE_FILE = '.gstack/tmp/pregate.json';
|
||||
const POLICY = {
|
||||
bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md',
|
||||
stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'plugin.json', 'README.md'],
|
||||
stamp_patterns: ['"version": "{version}"', '<!-- fixture-stamp: {version} -->'],
|
||||
};
|
||||
const PLUGIN = (v: string, description = 'Fixture plugin') => `{\n "name": "fx",\n "version": "${v}",\n "description": "${description}"\n}\n`;
|
||||
const STAMP_FILES = [
|
||||
{ path: 'CHANGELOG.md', rule: 'changelog-heading' }, { path: 'README.md', rule: 'stamp_files' }, { path: 'VERSION', rule: 'version-file' },
|
||||
{ path: 'package.json', rule: 'manifest-version' }, { path: 'plugin.json', rule: 'stamp_files' },
|
||||
];
|
||||
const ANCHOR = '(PREGATE_RECEIPT_INVALID; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pregate-receipt-invalid)';
|
||||
|
||||
/** A gated repo with a stamp rule on main: the feature branch's real change is committed as the gated head G. */
|
||||
function gatedRepo(): { repo: FixtureRepo; gated: string; gatedTree: string } {
|
||||
const repo = makeFixtureRepo({
|
||||
pregate: { ...BASE_PREGATE, remote_gate: GATE, dependencies: { ...BASE_PREGATE.dependencies, 'CHANGELOG.md': [], 'plugin.json': [], VERSION: [] } },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify(POLICY, null, 2) + '\n', VERSION: '1.0.0\n', 'plugin.json': PLUGIN('1.0.0'), 'README.md': '# fixture\n<!-- fixture-stamp: 1.0.0 -->\n', 'CHANGELOG.md': '# Changelog\n\n## [Unreleased]\n\n- add\n' },
|
||||
});
|
||||
repos.push(repo);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { 'scripts/remote-gate.js': "console.log('PREGATE_REMOTE: tree=' + process.env.PREGATE_TREE + ' tests=3 failed=0');\n" }, 'remote gate');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--ff-only', 'main');
|
||||
const gated = commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change (gated ahead)');
|
||||
return { repo, gated, gatedTree: repo.git('rev-parse', `${gated}^{tree}`) };
|
||||
}
|
||||
|
||||
/** /ship's restamp on top of the gated head: every stamp file moves 1.0.0 -> 1.0.1 and nothing else. */
|
||||
function stamp(repo: FixtureRepo): string {
|
||||
const pkg = JSON.parse(readFile(repo, 'package.json'));
|
||||
pkg.version = '1.0.1';
|
||||
return commit(repo, {
|
||||
VERSION: '1.0.1\n', 'package.json': JSON.stringify(pkg, null, 2) + '\n', 'plugin.json': PLUGIN('1.0.1'),
|
||||
'README.md': '# fixture\n<!-- fixture-stamp: 1.0.1 -->\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.1] - 2026-10-26\n\n- add\n',
|
||||
}, 'v1.0.1 stamp');
|
||||
}
|
||||
|
||||
/** pregate.json for the current head on a 4-core machine: the test tier is the remote_gate obligation, uncleared. */
|
||||
async function pregateAt(repo: FixtureRepo) {
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
const file = await runPregate(loaded.ctx, { stage: 'tests' });
|
||||
expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]);
|
||||
fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, PREGATE_FILE), JSON.stringify(file));
|
||||
return file;
|
||||
}
|
||||
|
||||
const receiptFile = (repo: FixtureRepo, tree: string, tests = 3, failed = 0) => { const f = path.join(repo.root, `gate-${Math.random().toString(36).slice(2)}.log`); fs.writeFileSync(f, `gate log\nPREGATE_REMOTE: tree=${tree} tests=${tests} failed=${failed}\n`); return f; };
|
||||
const clear = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, ...args]);
|
||||
const pregateJson = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, PREGATE_FILE), 'utf8'));
|
||||
const policyLabel = (repo: FixtureRepo) => `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}:.gstack/ship-policy.json`;
|
||||
const describeStamps = STAMP_FILES.map(f => `${f.path} (${f.rule})`).join(', ');
|
||||
|
||||
describe('clear --receipt accepts a stamp-only predecessor tree (gate ahead of the stamp)', () => {
|
||||
test('a receipt for the gated tree clears the stamped head; the substitution names the trees, the policy and every file with its rule, and verify passes', async () => {
|
||||
const { repo, gated, gatedTree } = gatedRepo();
|
||||
const head = stamp(repo);
|
||||
const file = await pregateAt(repo);
|
||||
expect(file.tree).toBe(repo.git('rev-parse', `${head}^{tree}`));
|
||||
expect(file.tree).not.toBe(gatedTree);
|
||||
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
|
||||
expect(r.err).toBe('');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain(`PREGATE_RECEIPT_SUBSTITUTED: gated tree ${gatedTree.slice(0, 12)} accepted for tree ${file.tree.slice(0, 12)} under ${policyLabel(repo)}; stamp-only: ${describeStamps}`);
|
||||
expect(r.out).toMatch(new RegExp(`^PREGATE_CLEARED: pregate\\.json/remote_gate receipt=remote-gate tree=${gatedTree.slice(0, 12)} tests=3 accepted-for=${file.tree.slice(0, 12)} stamp-only=5 receipt=\\S+ sha256=[0-9a-f]{12} verdict=requires-remote cleared$`, 'm'));
|
||||
const stored = pregateJson(repo).requires_remote[0];
|
||||
expect(stored.receipt).toMatch(/^remote-gate tree=/);
|
||||
expect(stored.cleared_by).toMatch(/^gstack-pregate clear /);
|
||||
expect(stored.substitution).toEqual({ gated_tree: gatedTree, accepted_for: file.tree, policy: policyLabel(repo), files: STAMP_FILES });
|
||||
// The same rule the tree receipt applies to the same pair: identical files and verdicts.
|
||||
const receipt = JSON.parse(runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--current', head, '--base', 'main', '--json']).out);
|
||||
expect(receipt.tree).toBe('same-modulo-stamps');
|
||||
expect(receipt.files.map((f: any) => ({ path: f.path, rule: f.rule }))).toEqual(STAMP_FILES);
|
||||
const verify = runBin(repo, 'gstack-pregate', ['verify']);
|
||||
expect(verify.status).toBe(EXIT.ok);
|
||||
expect(verify.out).toContain(`PREGATE_VERIFY: ok pregate requires-remote cleared is current for tree ${file.tree.slice(0, 12)}; pregate.json/remote_gate: gated tree ${gatedTree.slice(0, 12)} accepted for tree ${file.tree.slice(0, 12)} under ${policyLabel(repo)}; stamp-only: ${describeStamps}`);
|
||||
}, 120_000);
|
||||
|
||||
test('a receipt for the very tree pregate.json recorded still clears with no substitution', async () => {
|
||||
const { repo } = gatedRepo();
|
||||
stamp(repo);
|
||||
const file = await pregateAt(repo);
|
||||
const r = clear(repo, ['--receipt', receiptFile(repo, file.tree)]);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).not.toContain('PREGATE_RECEIPT_SUBSTITUTED');
|
||||
expect(r.out).not.toContain('accepted-for=');
|
||||
expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined();
|
||||
}, 120_000);
|
||||
|
||||
test('a path outside the stamp rule refuses the receipt, names the path, and leaves the obligation uncleared', async () => {
|
||||
const { repo, gatedTree } = gatedRepo();
|
||||
stamp(repo);
|
||||
commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n' }, 'a real edit after the gate');
|
||||
const file = await pregateAt(repo);
|
||||
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain(`receipt refused — the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}, and the trees differ beyond the stamp rule of ${policyLabel(repo)}: lib/retry.js (stamp_files: not in the repo's stamp_files allowlist, line 1); pregate.json/remote_gate stays uncleared; fix: rerun the remote gate on tree ${file.tree.slice(0, 12)} and pass its output with --receipt`);
|
||||
expect(r.err).toContain('a receipt from a predecessor tree is accepted only when every differing path is a stamp');
|
||||
expect(r.err).toContain(ANCHOR);
|
||||
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
|
||||
expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined();
|
||||
const verify = runBin(repo, 'gstack-pregate', ['verify']);
|
||||
expect(verify.status).toBe(EXIT.fail);
|
||||
expect(verify.err).toContain('PREGATE_REMOTE_UNCLEARED');
|
||||
}, 120_000);
|
||||
|
||||
test('a stamp path whose hunk is not the stamp rewrite is a change: refused with the path, the rule and the line', async () => {
|
||||
const { repo, gated, gatedTree } = gatedRepo();
|
||||
stamp(repo);
|
||||
commit(repo, { 'plugin.json': PLUGIN('1.0.1', 'Fixture Plugin') }, 'description edit inside a stamp file');
|
||||
const file = await pregateAt(repo);
|
||||
const r = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain(`the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}, and the trees differ beyond the stamp rule`);
|
||||
expect(r.err).toContain(': plugin.json (stamp_files: "\\"description\\": \\"Fixture plugin\\"" -> "\\"description\\": \\"Fixture Plugin\\"", line 4); pregate.json/remote_gate stays uncleared; fix: ');
|
||||
expect(r.err).toContain(ANCHOR);
|
||||
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
|
||||
// The tree receipt agrees: the pair is `changed` on the same path.
|
||||
const receipt = JSON.parse(runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--current', 'HEAD', '--base', 'main', '--json']).out);
|
||||
expect(receipt.residual).toEqual(['plugin.json']);
|
||||
}, 120_000);
|
||||
|
||||
test('a gated tree that is not in the object store, or an unreadable receipt file, refuses with the fix', async () => {
|
||||
const { repo } = gatedRepo();
|
||||
stamp(repo);
|
||||
const file = await pregateAt(repo);
|
||||
const unknown = clear(repo, ['--receipt', receiptFile(repo, 'deadbeef'.repeat(8))]);
|
||||
expect(unknown.status).toBe(EXIT.fail);
|
||||
expect(unknown.err).toContain(`the remote gate tested tree deadbeefdead, not ${file.tree.slice(0, 12)}; the gated tree deadbeefdead is not an object in this repository, so the two trees cannot be compared; pregate.json/remote_gate stays uncleared; fix: fetch the commit the remote gate tested (git fetch origin <its sha>) so its tree is here, then clear again`);
|
||||
expect(unknown.err).toContain(ANCHOR);
|
||||
const missing = clear(repo, ['--receipt', path.join(repo.root, 'no-such-receipt.log')]);
|
||||
expect(missing.status).toBe(EXIT.fail);
|
||||
expect(missing.err).toContain('receipt refused — cannot read ');
|
||||
expect(missing.err).toContain(ANCHOR);
|
||||
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
|
||||
}, 120_000);
|
||||
|
||||
test('a predecessor receipt that reports zero tests or a failure is still refused on its own terms', async () => {
|
||||
const { repo, gatedTree } = gatedRepo();
|
||||
stamp(repo);
|
||||
await pregateAt(repo);
|
||||
const zero = clear(repo, ['--receipt', receiptFile(repo, gatedTree, 0)]);
|
||||
expect(zero.status).toBe(EXIT.fail);
|
||||
expect(zero.err).toContain('the remote gate reported 0 tests; pregate.json/remote_gate stays uncleared');
|
||||
const failed = clear(repo, ['--receipt', receiptFile(repo, gatedTree, 3, 1)]);
|
||||
expect(failed.status).toBe(EXIT.fail);
|
||||
expect(failed.err).toContain('the remote gate reported 1 failed of 3');
|
||||
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
|
||||
}, 120_000);
|
||||
|
||||
test('verify refuses a recorded substitution that was accepted for another tree', async () => {
|
||||
const { repo, gatedTree } = gatedRepo();
|
||||
stamp(repo);
|
||||
const file = await pregateAt(repo);
|
||||
expect(clear(repo, ['--receipt', receiptFile(repo, gatedTree)]).status).toBe(EXIT.ok);
|
||||
const edited = pregateJson(repo);
|
||||
edited.requires_remote[0].substitution.accepted_for = 'cafe'.repeat(10);
|
||||
fs.writeFileSync(path.join(repo.work, PREGATE_FILE), JSON.stringify(edited));
|
||||
const v = verifyPregate(repo.work, edited, { tree: file.tree });
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.code).toBe('PREGATE_REMOTE_UNCLEARED');
|
||||
expect(v.message).toContain(`pregate.json/remote_gate (accepted for cafecafecafe, pregate.json is ${file.tree.slice(0, 12)}); clear again`);
|
||||
const r = runBin(repo, 'gstack-pregate', ['verify']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain('PREGATE_REMOTE_UNCLEARED');
|
||||
}, 120_000);
|
||||
|
||||
test("without a repository root clearDecision keeps the plain tree mismatch; baseBranchOf reads pregate.json's base field", async () => {
|
||||
const { repo, gatedTree } = gatedRepo();
|
||||
stamp(repo);
|
||||
const file = await pregateAt(repo);
|
||||
const d = clearDecision(file, LANE, { receiptPath: receiptFile(repo, gatedTree) });
|
||||
expect(d).toEqual({ refused: 'PREGATE_RECEIPT_INVALID', message: `the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}; pregate.json/remote_gate stays uncleared` });
|
||||
expect(baseBranchOf(file.base)).toBe('main');
|
||||
expect(baseBranchOf('origin/release/2026@0123456789ab')).toBe('release/2026');
|
||||
expect(baseBranchOf(undefined)).toBeNull();
|
||||
expect(baseBranchOf('main')).toBeNull();
|
||||
const s = substituteStampOnlyTree(repo.work, 'main', gatedTree, file.tree);
|
||||
expect('substitution' in s && s.substitution.files).toEqual(STAMP_FILES);
|
||||
const noBase = substituteStampOnlyTree(repo.work, 'no-such-branch', gatedTree, file.tree);
|
||||
expect('problem' in noBase && noBase.problem).toContain('the ship policy on origin/no-such-branch could not be read');
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in new issue
Block a user