Files
gstack/lib/pregate/remote-receipt.ts
Garry Tanandcapy-ai[bot] e474a4fb53 v1.91.336.0 ship-queue: assign VERSION/CHANGELOG at merge time (changelog fragments + gstack-ship-queue land) (#3594)
* feat(ship-policy): changelog_fragments key; restamp folds fragments; tree receipt accepts the fold

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

* feat(ship-queue): land, the per-PR merge step; Version Gate enforces fragments

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

* feat(ship-queue): land --repair after drift; hook base sha; docs and CHANGELOG

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

* test: timeout on the land gh shim's git spawn

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

* feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567)

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

* test(land): keeps the base's four-part package.json (#3557)

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

* fix(ship-queue): land waits for CI through runScriptSync (script-invocation tripwire)

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

* test: land, fragment and Version Gate cases from /ship's coverage audit

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

* test(windows): leave test/ship-queue-land.test.ts out of the Windows-safe subset (gh shim cannot launch there)

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

* docs(ship-policy): rollback lands open fragment PRs first; restamp folds only with the key on

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

* fix(land): close pre-landing review findings

land without --no-wait now compares the pushed branch head and polls the
stamp commit's checks, refuses a PR whose base is not the stamped base,
releases the lock on a pre-push failure, keeps --break-lock off drift
locks, resumes from a lost journal via the stamp commit's pin, honours
history merge-only, and refuses a fragment whose text is already released
on the base. Version Gate stops applying open claims to a fragments stamp.

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

* fix(land): close adversarial and outside-voice findings

land reconciles a merge GitHub completed behind an error reply, refuses
--break-lock against a holder that just entered a push or a merge, keeps
a repair takeover on the lock itself (any checkout can land the repair
PR), resumes --repair after a failed PR create, releases the lock on
QUEUE_LAND_BASE_BUSY, never writes through a symlinked release file,
keeps a package.json version source's other edits, and refuses an empty
object id instead of pushing a deletion. A carried-back fragment is
matched on whole released lines. Under changelog_fragments Version Gate
accepts a stamped head only as a [pin, base] merge-step commit with valid
fragments.

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

* feat(land): refuse a pin without a READY receipt; symlinked release files refuse before restamp

Under changelog_fragments, gstack-ship-queue ready records a READY receipt
(refs/notes/gstack-trains) once its pregate, full_e2e and /ship receipt
checks pass, and land refuses a pin without one (fix: run
gstack-ship-queue ready --pr N), --dry-run included (GStack Minder ruling,
2026-10-29). The merged tree's release files may not be symlinks, so
restamp never writes outside the scratch worktree. --repair adopts an
existing branch only when it holds land's repair commit.

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

* fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready

gstack-pregate clear and gstack-tree-receipt now classify through one
classifyFiles, so under changelog_fragments a receipt for the fragment head
clears the stamped head when the stamp is exactly the fold. The ship-policy
reference names ready's receipt push, land's own check polling, the key-off
refusal and the /ship changelog_fragments line.

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

* docs(pregate, ship-policy): the fragment fold's two receipt rules, and clear applies them too

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

* ship: write the Steps 12-16 range with an ASCII hyphen so the skeleton fits maxSkeletonBytes

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>
2026-10-30 16:54:58 +00:00

124 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* `classifyFiles`, the changelog_fragments fold included: 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, including its
* gate rule: a repo whose `stamp_sensitive_gates` names the test lane (`tests`,
* as gstack's own policy does, because its tests read VERSION and the digest)
* is refused, since the tree receipt never marks such a lane reusable.
*
* 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 type { PregateReceiptSubstitution } from '../headless-artifacts';
import { parseUnifiedDiff, versionAtRef } from '../restamp';
import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy';
import { classifyFiles } from '../tree-receipt';
import { treeMismatch } from './checks/lanes';
/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */
export type StampOnlySubstitution = PregateReceiptSubstitution;
export type SubstitutionResult = { substitution: StampOnlySubstitution } | { problem: string; fix: string };
/** A git object id as the remote gate and bin/gstack-wtree print it: nothing symbolic (`HEAD^{tree}`) or option-shaped reaches git. */
const OBJECT_ID = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
/** The `stamp_sensitive_gates` labels that name the test tier the remote gate runs: gstack-evidence's `tests` lane, or the obligation itself. */
const STAMP_SENSITIVE_TEST_LANES = new Set(['tests', 'remote_gate']);
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;
}
/**
* The fix for a pregate.json recorded against `recorded` when the ship's base
* is `base`. `resolveBaseBranch` falls back to `main` when origin/HEAD is unset,
* so a repo whose base is another branch is told how to set it rather than
* sent to rerun against a branch it may not have.
*/
export function baseMismatchFix(repoRoot: string, base: string, recorded: string): string {
const fix = `rerun gstack-pregate --base ${base}, then clear again`;
if (git(repoRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) !== null) return fix;
return `${fix}; or, since origin/HEAD is unset here, git remote set-head origin ${recorded} so clear reads that base's policy`;
}
/**
* 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 (opening with the same tree-mismatch clause `remoteReportProblem`
* prints) 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 = treeMismatch(gatedTree, 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 (!OBJECT_ID.test(tree)) return { problem: `${mismatch}; the ${label} tree ${JSON.stringify(tree.slice(0, 40))} is not a git object id, so it cannot be compared`, fix: rerun };
if (git(repoRoot, ['cat-file', '-t', '--end-of-options', 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.source.kind === 'base' && !loaded.present) return { problem: `${mismatch}; ${SHIP_POLICY_PATH} exists on ${loaded.source.label} but could not be read, so the stamp rule is unknown`, fix: `git fetch origin ${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}` };
// The tree receipt never marks a stamp-sensitive lane reusable: a repo whose tests read VERSION or generated bytes says so here, and its test tier is what the remote gate ran.
const sensitive = loaded.policy.stamp_sensitive_gates.filter(g => STAMP_SENSITIVE_TEST_LANES.has(g));
if (sensitive.length) return { problem: `${mismatch}, and ${loaded.source.label} lists the test lane as stamp-sensitive (stamp_sensitive_gates: ${sensitive.join(', ')}), so a run on another tree never covers this one`, fix: 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;
// The patch is read with a fixed presentation (no textconv, no rename pairing, submodules included, a/ b/ prefixes) and
// reconciled against the plumbing inventory of the pair, so a diff driver or diff.* setting can neither hide a path nor rename it.
const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--ignore-submodules=none', '--src-prefix=a/', '--dst-prefix=b/', '--end-of-options', gatedTree, headTree]);
if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
const inventory = git(repoRoot, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', '--ignore-submodules=none', '--end-of-options', gatedTree, headTree]);
if (inventory === null) return { problem: `${mismatch}; git diff-tree ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
const files = classifyFiles(repoRoot, parseUnifiedDiff(diff), gatedTree, headTree, loaded.policy, versionRel, oldV, newV);
if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun };
const expected = new Set(inventory.split('\0').filter(Boolean));
const seen = new Set(files.map(f => f.path));
const unaccounted = [...expected].filter(p => !seen.has(p)).concat([...seen].filter(p => !expected.has(p)));
if (unaccounted.length) return { problem: `${mismatch}; the patch between the trees does not account for every differing path (${unaccounted.join(', ')}), so the difference cannot be classified`, 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(', ')}`;
}