mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: trim two phrases for headroom with #3662 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship goldens Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251) gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: no test edit skips review; guard packet writes; exclusive attempt state A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: attempt prints gstack's install command; carry checks the product diff outside context paths The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(unattended): carry's scoped-review refusal and what it keeps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals - A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account for the claimed issue and critical counts (GST-430). - The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442). - patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448). - Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the Step 16 --docs-requested route with its --docs-result Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept - deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot dispose of a gate finding the unattended logger forces open (outside-voice#1). - reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses; noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL. - patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT - isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition. - reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a stale local base branch yields to its origin twin in gate-diff and diff-scope (GST-462) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 11 saves the targeted record and the receipt writes carry it; Step 9 clears it (GST-464) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-464 and GST-462 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): pass 1: ahead and diverged labels, diff-scope needs shared history; preflight refreshes origin/<base> and stops on a diff far beyond the PR (GST-462, HARN-15) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes for base twin, prSized stop and targeted record Resolve the tracking twin by refs/remotes/<remote>/<ref> and only for a bare branch name, keep a local base that is ahead or diverged, quote the prSized stop's base and name the fork case, and have every Step 9 entry create, exclude and clear .gstack/tmp/targeted.json. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: origin's tracking base wins whenever related; prSized lower bound The fix-check found that keeping a local base ahead of origin left unpushed base commits out of the gate scan while review-plan listed them. The tracking branch now wins whenever it shares history with HEAD, an explicit origin/main reads refs/remotes/origin/main, the preflight also stops when the diff lists fewer files than the PR, and the Step 9 exclude line survives a missing .git/info and an exclude file without a final newline. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: the tracking-ref claim holds while the tracking branch exists Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the two ship section briefs under no-loss replay GST-464 GST-464 brief-identity check: every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md stays byte-identical to the bodies the L and GST-257 replays admitted; only lead instructions outside the cut spans changed. The check is a test CI runs. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: keep Step 9's PASS_HEAD sentence whole; repin review-army under GST-464 The targeted-record line follows the PASS_HEAD/RANGE_BASE sentence instead of splitting it (test/review-fix-range.test.ts pins that sentence). The brief-body check still holds: every reviewer brief is byte-identical. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore bin/gstack-ship-receipt after the main merge The merge commitb10e2dbwrote a truncated copy of the file. This is the three-way merge of both sides with the refused() union: main's codes plus RECEIPT_CARRY_SCOPED_REVIEW. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: narrow the consumer scan and give it one 300 s deadline (GST-544) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: keep the consumer scan deadline under the 180 s plan timeout (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: plain paths under --literal-pathspecs; whole-millisecond timeout override (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: whole-tree grep for non-UTF-8 names, no argument spread, name spawn errors (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: cap the consumer grep output at 256 MB across calls; per-scan deadline wording (review fix, cycle 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: read a missing spawn status as null (Bun 1.4.2 returns undefined on ENOENT) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-collect whole-tree helper reads a null status as -1 (typecheck:test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: /review and /ship triage every configured review bot, not only Greptile (#3122) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (gh before 2.48 pages without --slurp; driver routes Step 10; tests for guards, review-bot fence, REMOTE_SLUG owner) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: drop the review-bot fence test that could not fail (the tool-fence WARN degrade is unreachable: placeholder spans are skipped first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (record by comment id so a filename never reaches a shell; lookup failures are errors; tojson pages; longest bot prefix wins) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes (record refuses * paths, colliding ids and a missing fetch dir; parent requires id and dir; botOwning doc) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: classify the PR type and always check repo conventions (#356) The checklist now opens with a PR-type line (application, CI/infra, scripts/tooling, docs-only or mixed). Application and mixed diffs run every category; other types always run five categories, including the new Repo Conventions & Platform Fit, and report the rest as not applicable in one line instead of skipping silently. No-loss replay: GSTA-224 H1 preregistered no-loss run (run id gsta224-h1-20261031, Opus 5.5, 81 calls, $9.53) against main's checklist with a no-gstack arm; every preregistered bar passed. The review/checklist.md pin in test/review-packets-removed.test.ts moves to a32963ef with replay 'GSTA-224 H1'. Refs GSTA-224 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: allowlist the record placeholders and match Step 10's id rule in tests The record fence uses <comment-id>, <fix|fp|already-fixed> and <category>, each with an enum or numeric grammar; <greptile-dir> becomes <bot-dir>. review/greptile-triage.md also lists every REVIEW_BOTS skip reason. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: review-bot triage covers every configured bot (#3122) README, CONTRIBUTING, the contributor reference and standalone-binaries name gstack-review-bots and the review-bot fence; TODOS drops the #3122 item; the changelog fragment covers record-by-id and lookup errors. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the PR-type test also pins the compose, Terraform and README/CHANGELOG phrases Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the review-bot rename in the remote free tier's three reds The cross-skill consistency judge describes review-bot-history.md and its record helper; the tracker-guard exemption for the removed raw Greptile fetch in review/greptile-triage.md is dropped (the fetch now lives in gstack-review-bots, whose bodies still reach context only through gstack-issue-guard); the Step 10 PR-check test expects 'Review bots: not run'. HARNESS_VERSION stays 6: skill-fixture.ts and llm-judge.ts are recorded non-behavioral for every other case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the cross-skill judge's rubric to one renamed bullet Name review-bot-history.md beside the older greptile-history.md so the calibration corpus still matches, and keep the REMOTE_SLUG bullet. One live 3-sample panel at this tree: consistent 3/3, scores 4/4/4. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: H1 fragment passes prose lint Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: name the H1 fragment after its branch; TODOS drops #356 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: /review's PR type step in docs/skills.md (#356) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-policy: review_exclude_paths key and loadPolicyAt (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: apply the base policy's review_exclude_paths and the reviewer diff ceiling (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * receipt: bind review_exclude in write, carry and the queue (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs, tests and changelog for review_exclude_paths (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (binary by bytes, links kept, untracked files out of the selection) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep binary files at code paths under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the reasons that keep a binary file under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep every review_exclude_paths candidate when the gate scan is unknown; docs example names its version Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the NUL fixture clear of the sync-spawn tripwire Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: split the ceiling sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep a live binary file whose bytes cannot be read; tests render outside voices from a clean checkout and read the ship fixture text Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-exclude): explicit +x for the census script; skip the chmod-000 read case on Windows and root Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * refactor: move loadPolicyAt to lib/ship-policy-at.ts so lib/ship-policy.ts stays under the 800-line module cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate gstack-doctor-components.sh for BASE_CONTAINS_HEAD Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: land resumes a merged merge-only PR instead of refusing BASE_CONTAINS_HEAD (GST-774 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for GST-774 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting names diff-scope base_contains_head and the self-pointing no_base case (GST-774 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the reviewer briefs to GST-508's no-loss-tested bytes ship/sections/review-army.md, ship/sections/adversarial.md and lib/review-depth.ts now pin the bytes the GST-508 preregistered no-loss replay ran atef2760f(handoffs/GSTA-202/noloss-results.md in the GStack Minder drive): Arm V 33/41 vs R 33/41, delta 4, exact sign flip, p_NI 0.0279 (pass). The merge of main83ae96eleaves these three files byte-identical toef2760f, and an Arm P unit (3593) renders byte-identical briefs atef2760fand at the merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: GST-774 review round 1 — merged resume depth, aliased and detached PR checkouts, develop/trunk defaults Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: Step 20 says a metrics row, keeping DRIVER.md at 12 KB after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ship-parts resend case leaves room for the first phase-3 part after the Step 9 targeted-record sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship delivers review/checklist.md in two parts so checklist v1 fits the 24576-byte part cap (#3670) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: drop the GST-407 and GST-430 fragments, already released on main (stacked history) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: lib/ship-policy.ts back to 800 lines (module-size ratchet after merging #3744 and #3795) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: /ship pass 1 fixes - lib/ship-receipt-depth.ts: a merged resume (land after a true merge) no longer recomputes the review_exclude digest over the emptied range, so it lands instead of RECEIPT_INVALID (failing-first land test with review_exclude_paths active). - bin/gstack-review-dispatch render: a standalone targeted render under an active review_exclude_paths refuses and points at the pass manifest voices stage, instead of writing a brief with no selection that leaves verify dirty (failing-first test). - test/default-base.test.ts: #3795 expectations follow main R51 depth (light for an 80-line code diff; runtime_lines=80 still proves the real base). - test/review-dispatch.test.ts: the GST-897 fingerprint test renders from the clean checkout, like its neighbor, because CI rebuilds the tracked lib/diagram-render/dist/diagram-render.html and the reviewer diff ceiling refused the ROOT render. * wave review-53cfb5bfe011: /ship pass 2 fixes - lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs. - lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test. - test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment). * fix: merge follow-ups for main3d99c22: origin/* stays qualified in diff-scope's twin step, retro skeleton back under its cap, combined no-loss pin GST-508+R51+GST-911 * fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/<base> (#3852 /ship red-team#1, outside-voice#1) * test: the docs-only receipt digest test keeps its gate scan out of the real home (free-suite shard 5 FREE_SUBSHARD_REJECTED) * fix(mergefc292e22f): tentative REVIEW_EXCLUDE_KEY_VERSION, classify targeted.json, render main's new outside-voice test from a clean checkout - lib/ship-policy.ts: REVIEW_EXCLUDE_KEY_VERSION 1.93.0.0 -> 1.94.0.0, the next-minor tentative form stamp_paths moves (docs/ship-policy.md, #3512); 1.93.0.0 is a released heading that never names the key. - test/gstack-clean-producers.test.ts (new from main): KEPT gains targeted.json, the wave's --review-targeted input. - test/review-dispatch.test.ts: main's wrong-tree test rendered the outside voice from ROOT; a CI build that dirties ROOT pushes that diff past the wave's REVIEW_DIFF_MAX_BYTES ceiling, so render refused and verify saw no brief (exit 3). It now runs in cleanCheckout like the wave's other outside-voice tests. * test(merge68410828f): GST-967's brief-body pin carries the wave's GST-508 placeholders The merged sectionTexts differ from main's by exactly the wave's GST-508 exclude-pathspec lines (merged minus main equals wave minusfc292e22f); the 1,440-case plan grid still hashes to main's MAIN_PLANS. Checks: GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md. * ship: CI carries mapped lanes, --reuse tree cache, early push, finish/accept split (GSTA-258 A) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: name the ship and pregate bins in prose so skill-bin-drift does not read a bin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * size-ledger: re-record ship after the tests.md wording fix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * pregate: a carried run clears the remote gate through remote_gate_runs, so a paid-only remainder is not a dead end Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * merge cleanup: drop wave fragments main already consumed and a duplicated KEPT entry Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): record-ci verifies the CI run itself; finish's deferred preflight keeps pregate.json /ship pass 1 findings red-team#1 and specialist-batched#1 (GSTA-258 piece A). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(pregate): CI carry is gstack's default remote gate route; the VM is opt-in (plan choice F1) The uncleared remote_gate obligation names the CI route first when the base declares remote_gate_runs. docs/pregate.md says how gstack clears it, including the pull_request merge-ref tree mismatch. GSTA-258 piece A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (round 2) record-ci records the base policy that mapped the lane; the CI-first remote gate hint respects --no-ci-carry and reruns the whole pregate; changelog and docs say what the tool does. GSTA-258 piece A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CI carry in CONTRIBUTING, recommended-harness and the pregate usage line (ship docs audit) 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>
528 lines
46 KiB
TypeScript
528 lines
46 KiB
TypeScript
/**
|
||
* ship-release — /ship Step 12 (version), Steps 15-16 (release commit, verification)
|
||
* and Step 17 (push) as gstack-ship-release's three commands. They chain
|
||
* gstack-ship-policy, gstack-version-bump, gstack-next-version, gstack-decision-log,
|
||
* gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate (the
|
||
* stamped-tree rerun and the sealed guard) and git, print a compact result, and stop
|
||
* with STOP: wherever the prose assigns the decision to the agent: the bump level, a
|
||
* rebump, an incomplete queue, a sibling's claim, feature-commit grouping, a changed
|
||
* reviewed tree, stale docs, failing or zero-run tests, the pre-push offer and pushes
|
||
* that fail.
|
||
*/
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { mergeChangelogs, normalizeChangelog } from './changelog-check';
|
||
import { versionAtRef } from './restamp';
|
||
import { classifyPath } from './review-depth';
|
||
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
|
||
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
|
||
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||
import { resolveStateRoot } from './state-root';
|
||
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
|
||
import { waitForRun } from './ci-carry';
|
||
import { buildRunCheck } from './pregate/run-check';
|
||
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
|
||
|
||
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
|
||
|
||
version --base <base> [--bump micro|patch|minor|major] [--version <v>] [--keep-current] [--accept-incomplete] [--why <text>] [--answer <owner's words>]
|
||
Step 12: classify, pick the queue-aware version, write it with --regen-digest --changelog, log the decision;
|
||
--keep-current (or --version <current>) keeps VERSION; under bump ask a session no person answers needs --answer before a bump is written
|
||
finish --base <base> --reviewed-tree <tree> [--reviewed-base <sha>] --docs-post <post.json> [--lane <label>=<command>]... [--build <command>]...
|
||
[--trailer <text>] [--docs-rechecked] [--no-lanes-approved] [--context-reviewed <record.json>]
|
||
[--docs-requested <path>... --docs-result <the audit's result JSON>] [--review-loop-exception <json|@file>]
|
||
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
|
||
each lane's receipt run once if stale) and the pregate on the stamped tree; --reviewed-base (the merge base
|
||
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=;
|
||
--review-loop-exception (the record write takes) routes paths the branch touched at fix_check.head (narrowed to fix_check.files when listed) and changed since as accepted=
|
||
A lane the base branch maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact
|
||
tree, else deferred (EVIDENCE: DEFERRED): the pregate then runs its preflight stage here and its tests stage in accept
|
||
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved] [--early [--base <base>]]
|
||
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u;
|
||
--early (the Phase 2 push before review, so CI runs beside it) also runs pregate's secrets scan on the commits first
|
||
accept --base <base> --lane <label>=<command>... [--wait <s>]
|
||
after push: final-tree CI acceptance. Waits up to --wait seconds (default 170) for the CI run of each mapped profile at
|
||
the pushed HEAD, carries each mapped lane from that run (a clean tree equal to the run's), reuses or runs the rest,
|
||
checks every lane FRESH and reruns the pregate on the final tree (its tests stage carries the same run)
|
||
|
||
At most 30 lines each; STOP: names what the agent must decide or repair, and nothing after it ran.
|
||
Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||
|
||
const LEVELS: Bump[] = ['micro', 'patch', 'minor', 'major'];
|
||
const KEPT_NEXT = 'Step 13: no version heading (the entry stays under ## [Unreleased], or is left to the repo\'s release process); an unprefixed Step 18 title and "version":null in Step 20';
|
||
|
||
function classify(cwd: string, base: string): { state?: string; json?: any; error?: string } {
|
||
const c = bin(cwd, 'gstack-version-bump', ['classify', '--base', base], 60_000);
|
||
if (c.status !== 0) return { error: firstLine(c.stderr) || `exit ${c.status}` };
|
||
try { const json = JSON.parse(c.stdout); return { state: json.state, json }; } catch { return { error: 'classify printed no JSON' }; }
|
||
}
|
||
|
||
/** The first component that changed from base to current (an absent fourth is zero). */
|
||
function derivedLevel(base: string, current: string): Bump | null {
|
||
const a = parseVersion(base), b = parseVersion(current);
|
||
if (!a || !b) return null;
|
||
const order: Bump[] = ['major', 'minor', 'patch', 'micro'];
|
||
const i = a.findIndex((n, k) => n !== b[k]);
|
||
return i < 0 ? null : order[i]!;
|
||
}
|
||
|
||
function queue(cwd: string, base: string, level: Bump, baseVersion: string): { version?: string; json?: any; note: string } {
|
||
const r = bin(cwd, 'gstack-next-version', ['--base', base, '--bump', level, '--current-version', baseVersion], 180_000);
|
||
let json: any;
|
||
try { json = JSON.parse(r.stdout); } catch { return { note: `unverified (gstack-next-version exit ${r.status}, no JSON)` }; }
|
||
const usable = r.status === 0 && typeof json.version === 'string' && parseVersion(json.version) && (json.offline === false || json.fallback === 'git');
|
||
return usable ? { version: json.version, json, note: json.offline ? 'git-fallback' : 'live' } : { json, note: 'unverified (offline without the git fallback, or no valid version)' };
|
||
}
|
||
|
||
export function version(argv: readonly string[], cwd: string): Report {
|
||
const { flag, has } = flagReader(argv);
|
||
const base = flag('--base')!;
|
||
const r = new Report();
|
||
const policy = shipPolicy(cwd, base);
|
||
const fragment = fragmentPath(policy, cwd);
|
||
if (policy?.queue_mode === 'stamp-at-merge' || fragment) return r.add(`VERSION: skipped (policy: ${fragment ? 'changelog_fragments' : 'queue_mode stamp-at-merge'} replaces Step 12 items 1-4; the merge stamps it)`).next(fragment ? `Step 13: write the entry as ${fragment} (entry text only, no heading), never an [Unreleased] section` : 'Step 13: write the CHANGELOG entry under ## [Unreleased]');
|
||
let c = classify(cwd, base);
|
||
if (c.error) return r.stop(`a configured version file is missing, empty, unreadable or malformed: ${c.error}; fix it or its pin, then rerun (never substitute 0.0.0.0)`);
|
||
const never = policy?.bump === 'never';
|
||
if (never && (c.state === 'ALREADY_BUMPED' || c.state === 'DRIFT_STALE_PKG')) return r.stop(`VERSION moved from ${c.json.baseVersion} to ${c.json.currentVersion} but the policy says bump never: put VERSION and the manifest back to the base version, or change the policy`);
|
||
if (c.state === 'DRIFT_STALE_PKG') {
|
||
const rep = bin(cwd, 'gstack-version-bump', ['repair'], 60_000);
|
||
r.add(`REPAIR: ${rep.status === 0 ? 'package manifest synced to VERSION' : `failed (${firstLine(rep.stderr)})`}`);
|
||
if (rep.status !== 0) return r.stop('gstack-version-bump repair failed; fix the manifest, then rerun');
|
||
c = classify(cwd, base);
|
||
if (c.error || c.state !== 'ALREADY_BUMPED') return r.stop(`after repair the state is ${c.state ?? c.error}; reconcile by hand, then rerun`);
|
||
}
|
||
const j = c.json;
|
||
if (c.state === 'NO_VERSION') return r.add(`VERSION: skipped (no version source: ${j.versionSource?.path ?? '-'}, ${j.versionSource?.reason ?? '-'})`).next('ship without a version change: no Step 13 entry heading, an unprefixed Step 18 title, "version":null in Step 20');
|
||
if (c.state === 'DRIFT_UNEXPECTED') return r.stop('package.json disagrees with VERSION while VERSION matches base: reconcile the manual edit, then rerun');
|
||
if (c.state !== 'FRESH' && c.state !== 'ALREADY_BUMPED') return r.stop(`unknown classify state ${c.state}`);
|
||
const explicit = flag('--version');
|
||
if (explicit && !parseVersion(explicit)) return r.stop(`--version ${explicit} is not a version`);
|
||
const fresh = c.state === 'FRESH';
|
||
const asked = flag('--bump') as Bump | undefined;
|
||
if (asked && !LEVELS.includes(asked)) return r.stop(`--bump ${asked} is not micro, patch, minor or major`);
|
||
if (never) {
|
||
if (asked || (explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) !== 0)) return r.stop(`policy bump never: unit PRs keep VERSION at ${j.baseVersion}; a release bump belongs to the release process (gstack-version-bump write), never Step 12`);
|
||
return r.add(`VERSION: ${j.baseVersion} kept (policy: bump never; no write)`).next(KEPT_NEXT);
|
||
}
|
||
const atBase = !!explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) === 0;
|
||
const keep = fresh && (has('--keep-current') || atBase);
|
||
const mover = asked ? `--bump ${asked}` : explicit && !atBase ? `--version ${explicit}` : '';
|
||
if (keep && mover) return r.stop(`${mover} moves VERSION and ${has('--keep-current') ? '--keep-current' : `--version ${explicit}`} keeps it: pass one`);
|
||
if (keep) return r.add(`VERSION: ${j.baseVersion} kept (FRESH, ${has('--keep-current') ? '--keep-current' : `--version ${explicit}`}; no write)`).next(KEPT_NEXT);
|
||
const level = fresh ? asked ?? (policy?.bump === 'patch' ? 'patch' : undefined) : asked ?? derivedLevel(j.baseVersion, j.currentVersion) ?? undefined;
|
||
if (!level) {
|
||
const stat = git(cwd, ['diff', `origin/${base}`, '--shortstat']).stdout.trim();
|
||
return r.stop(`BUMP_LEVEL needed (FRESH, ${stat || 'no diff'}): MICRO under 50 lines, PATCH 50+ with no feature signal, MINOR/MAJOR ask first (Step 12.2); rerun with --bump <level>, or with --keep-current to leave VERSION at ${j.baseVersion}`);
|
||
}
|
||
const answer = flag('--answer');
|
||
if (explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.baseVersion)!) <= 0) return r.stop(`${explicit} is not above the base version ${j.baseVersion}`);
|
||
const rewrites = fresh ? !!asked : !!explicit && cmpVersion(parseVersion(explicit)!, parseVersion(j.currentVersion)!) !== 0;
|
||
if (rewrites && policy?.bump !== 'patch' && !answer) {
|
||
const kind = bin(cwd, 'gstack-session-kind', [], 30_000).stdout.trim() || 'unknown';
|
||
const pick = [asked && `--bump ${asked}`, explicit && `--version ${explicit}`].filter(Boolean).join(' ');
|
||
if (kind !== 'interactive') return r.stop(`BUMP_ANSWER_REQUIRED (${kind} session, bump ask): a version Step 12 writes is the owner's approval, and a brief's "take the recommended option" is not an answer. Ask: ${pick} (recommended), keep ${fresh ? j.baseVersion : j.currentVersion} (--keep-current), or stop; then rerun with ${pick} --answer "<their words>". A repo whose unit PRs never bump sets bump never; one that always takes PATCH sets bump patch`);
|
||
}
|
||
let q = queue(cwd, base, level, j.baseVersion);
|
||
if (q.json?.complete === false) q = queue(cwd, base, level, j.baseVersion);
|
||
if (q.json?.complete === false && !has('--accept-incomplete')) return r.stop(`the queue read is incomplete (unknown_claims=${(q.json.unknown_claims ?? []).join(',')}): ask to use ${q.version ?? 'the pick'} (recommended; Version Gate rereads the queue) and rerun with --accept-incomplete, or stop`);
|
||
for (const w of (q.json?.warnings ?? []).slice(0, 3)) r.add(`QUEUE_WARNING: ${String(w).slice(0, 200)}`);
|
||
let next = explicit ?? (fresh ? q.version ?? fmtVersion(bumpVersion(parseVersion(j.baseVersion)!, level, versionWidth(j.baseVersion)), versionWidth(j.baseVersion)) : j.currentVersion);
|
||
if (!fresh && !explicit && q.version && q.version !== j.currentVersion && !has('--keep-current')) {
|
||
return r.stop(`ALREADY_BUMPED at ${j.currentVersion} but the queue picks ${q.version}: ask to rebump (rerun with --version ${q.version}; refresh the CHANGELOG and PR title) or keep (rerun with --keep-current; CI rejects a collision)`);
|
||
}
|
||
const sibling = (q.json?.active_siblings ?? []).find((s: any) => s.version && parseVersion(s.version) && cmpVersion(parseVersion(s.version)!, parseVersion(next)!) >= 0);
|
||
if (sibling && !explicit) return r.stop(`sibling ${sibling.branch} holds ${sibling.version} >= ${next}: choose to advance past it (rerun with --version <v>) or stop and sync`);
|
||
if (!fresh && explicit === undefined) {
|
||
r.add(`VERSION: ${j.baseVersion} -> ${next} level=${level} state=ALREADY_BUMPED queue=${q.note} (kept, no write)`);
|
||
return r.next(`Step 13: CHANGELOG entry under ${next}`);
|
||
}
|
||
if (cmpVersion(parseVersion(next)!, parseVersion(j.baseVersion)!) <= 0) return r.stop(`${next} is not above the base version ${j.baseVersion}`);
|
||
const changelog = typeof policy?.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
|
||
let w = bin(cwd, 'gstack-version-bump', ['write', '--version', next, '--regen-digest', '--changelog', changelog], 300_000);
|
||
if (w.status === 3) {
|
||
bin(cwd, 'gstack-version-bump', ['repair'], 60_000);
|
||
w = classify(cwd, base).state === 'ALREADY_BUMPED' ? { ...w, status: 0, stdout: w.stdout || '{}' } : w;
|
||
}
|
||
if (w.status !== 0) return r.stop(`gstack-version-bump write exited ${w.status}: ${firstLine(w.stderr)}${w.status === 2 ? ' (nothing was written)' : ''}`);
|
||
let wrote: any = {};
|
||
try { wrote = JSON.parse(w.stdout); } catch { /* reported below */ }
|
||
r.add(`VERSION: ${j.baseVersion} -> ${next} level=${level} state=${c.state} queue=${q.note} wrote=${wrote.wrote ?? next}`);
|
||
if (wrote.agentsDigest === false) r.next('run bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts and stage the digest with the bump');
|
||
const why = flag('--why') ?? `${level} bump from the diff`;
|
||
bin(cwd, 'gstack-decision-log', [JSON.stringify({ decision: `Ship ${next} (${level})`, rationale: answer ? `${why}; owner: ${answer}` : why, scope: 'repo', source: 'skill', confidence: 9 })], 30_000);
|
||
return r.next(`Step 13: CHANGELOG entry under ${next}`);
|
||
}
|
||
|
||
function commitRelease(cwd: string, release: string[], trailer: string | undefined, r: Report): boolean {
|
||
const dirty = git(cwd, ['status', '--porcelain', '-z']).stdout.split('\0').filter(Boolean).map(l => l.slice(3));
|
||
const feature = dirty.filter(p => !release.includes(p));
|
||
if (feature.length) { r.stop(`uncommitted changes outside release metadata: ${short(feature, 8)}; commit them in bisectable chunks with their tests and docs (Step 15), then rerun`); return false; }
|
||
const meta = dirty.filter(p => release.includes(p));
|
||
if (!meta.length) { r.add('COMMIT: nothing left to commit'); return true; }
|
||
git(cwd, ['add', '--', ...meta]);
|
||
const v = meta.includes('VERSION') && fs.existsSync(path.join(cwd, 'VERSION')) ? fs.readFileSync(path.join(cwd, 'VERSION'), 'utf8').trim() : null;
|
||
const msg = `${v ? `chore: bump version and changelog (v${v})` : 'chore: changelog and TODOS'}${trailer ? `\n\n${trailer}` : ''}`;
|
||
const c = git(cwd, ['commit', '-q', '-m', msg]);
|
||
if (c.status !== 0) { r.stop(`release commit failed: ${firstLine(c.stderr || c.stdout)}`); return false; }
|
||
r.add(`COMMIT: ${git(cwd, ['rev-parse', '--short=12', 'HEAD']).stdout.trim()} ${msg.split('\n')[0]} (${short(meta, 5)})`);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* A harness pin edit that only records changed harness files as non-behavioral at the same version and patterns (GST-1031): release
|
||
* metadata, like a version stamp. A bump, a pattern change or an entry without its reason is behavior.
|
||
*/
|
||
const HARNESS_PIN = 'scripts/harness-version.json';
|
||
function harnessRepin(cwd: string, from: string, to: string, p: string): boolean {
|
||
if (p !== HARNESS_PIN) return false;
|
||
const read = (tree: string): any => { const s = git(cwd, ['show', `${tree}:${p}`]); try { return s.status === 0 ? JSON.parse(s.stdout) : null; } catch { return null; } };
|
||
const a = read(from), b = read(to);
|
||
if (!a?.files || !b?.files || typeof b.files !== 'object' || a.version !== b.version || JSON.stringify(a.patterns) !== JSON.stringify(b.patterns)) return false;
|
||
return Object.entries(b.files as Record<string, any>).every(([f, e]) => JSON.stringify(a.files[f]) === JSON.stringify(e)
|
||
|| (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10));
|
||
}
|
||
|
||
/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */
|
||
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>;
|
||
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
|
||
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
|
||
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
|
||
const changedSet = touchedPaths(cwd, reviewed, now);
|
||
if (!changedSet) { r.stop(`the reviewed tree ${reviewed} is unreadable: a missing snapshot blocks stage 2`); return null; }
|
||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||
let sides: PatchSides | undefined;
|
||
let touched = new Set<string>();
|
||
if (reviewedBase !== undefined) {
|
||
const rb = git(cwd, ['rev-parse', '--verify', '--quiet', `${reviewedBase}^{commit}`]).stdout.trim();
|
||
if (!rb) { r.stop(`--reviewed-base ${reviewedBase.slice(0, 40)} is not a commit in this clone: pass the merge base saved with the reviewed tree (git merge-base ${baseRef} HEAD at Step 11)`); return null; }
|
||
if (git(cwd, ['merge-base', '--is-ancestor', rb, baseRef]).status !== 0) { r.stop(`--reviewed-base ${rb.slice(0, 12)} is not on ${baseRef}: pass the merge base saved with the reviewed tree (git merge-base ${baseRef} HEAD at Step 11)`); return null; }
|
||
const cb = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
|
||
const a = touchedPaths(cwd, rb, reviewed);
|
||
const b = cb ? touchedPaths(cwd, cb, now) : null;
|
||
if (!a || !b) { r.stop(`git could not diff the branch against ${baseRef} (fetch it first): stage 2 is blocked`); return null; }
|
||
sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now };
|
||
touched = new Set([...a, ...b]);
|
||
}
|
||
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] };
|
||
for (const p of [...changedSet].sort()) {
|
||
const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed';
|
||
by[verdict === 'same' ? 'base' : release.includes(p) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
|
||
}
|
||
const gated = gateEditedPaths(cwd, reviewed, now, by.tests) ?? new Set(by.tests);
|
||
by.behavior.push(...by.tests.filter(p => gated.has(p)));
|
||
by.tests = by.tests.filter(p => !gated.has(p));
|
||
const packetBody = sides && (by.context.length || by.tests.length) ? contextPacket(cwd, sides, by.context, by.tests) : null;
|
||
if (packetBody?.over.length) {
|
||
r.add(`SCOPED_REVIEW: too large for the scoped review (over ${PACKET_SECTION_CAP} lines in a section): ${short(packetBody.over)}`);
|
||
by.behavior.push(...packetBody.over);
|
||
by.context = by.context.filter(p => !packetBody.over.includes(p));
|
||
by.tests = by.tests.filter(p => !packetBody.over.includes(p));
|
||
}
|
||
const loop = opts.loop;
|
||
if (loop) {
|
||
const head = loop.fix_check.head;
|
||
const since = touchedPaths(cwd, head, now);
|
||
const fork = git(cwd, ['merge-base', baseRef, head]).stdout.trim();
|
||
const atHead = fork ? touchedPaths(cwd, fork, head) : null;
|
||
if (!since || !atHead) { r.stop(`git could not list the paths changed since fix-check ${head.slice(0, 12)} or the paths the branch touched at it against ${baseRef}: stage 2 is blocked`); return null; }
|
||
const listed = loop.fix_check.files;
|
||
const unscanned = [...by.behavior, ...by.docs].filter(isTestCode);
|
||
const forced = new Set([...gated, ...(packetBody?.over ?? []), ...(gateEditedPaths(cwd, reviewed, now, unscanned) ?? unscanned)]);
|
||
by.accepted = [...by.behavior, ...by.docs].filter(p => since.has(p) && atHead.has(p) && (!listed || listed.includes(p)) && !forced.has(p)).sort();
|
||
by.behavior = by.behavior.filter(p => !by.accepted.includes(p));
|
||
by.docs = by.docs.filter(p => !by.accepted.includes(p));
|
||
}
|
||
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`);
|
||
if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`);
|
||
if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; }
|
||
const scoped = [...by.context, ...by.tests].sort();
|
||
if (scoped.length && sides) {
|
||
if (!opts.contextReviewed) {
|
||
const packet = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', `gstack/scoped-review-${now.slice(0, 12)}.md`]).stdout.trim() || `.git/gstack/scoped-review-${now.slice(0, 12)}.md`);
|
||
const why = [by.context.length ? `base context moved next to the reviewed hunks in ${short(by.context)}` : '', by.tests.length ? `test code changed since review in ${short(by.tests)}` : ''].filter(Boolean).join('; ');
|
||
try {
|
||
fs.mkdirSync(path.dirname(packet), { recursive: true });
|
||
fs.writeFileSync(packet, contextPacket(cwd, sides, by.context, by.tests).text);
|
||
} catch (e) {
|
||
r.stop(`${why}, but the scoped-review packet ${packet} cannot be written (${firstLine((e as Error).message)}): make its directory writable, then rerun finish (stage 2.1)`);
|
||
return null;
|
||
}
|
||
r.stop(`${why}: one fix-check reviewer reads ${packet}, then rerun with --context-reviewed <record.json> written beside the packet, outside the worktree ({"tree":"${now}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding is a behavior change (stage 2.1)`);
|
||
return null;
|
||
}
|
||
const review = readScopedReview(opts.contextReviewed, { tree: now }, scoped);
|
||
if (!review.ok) { r.stop(`--context-reviewed: ${review.message}`); return null; }
|
||
r.add(`SCOPED_REVIEW: clean by ${review.review.reviewer} (${short(scoped)})`);
|
||
}
|
||
if (by.docs.length && !opts.docsRechecked) { r.stop(`authored docs changed since review (${short(by.docs)}): recheck the plan items they touch (stage 2.2), then rerun with --docs-rechecked`); return null; }
|
||
return by;
|
||
}
|
||
|
||
/**
|
||
* Step 16 stage 3. Test code, paths only the base or a stamp moved, a slot restamp after the audit (the CHANGELOG's heading
|
||
* move or re-heading after the restamp writer's own merge of the base's CHANGELOG into the audited one, the version file's
|
||
* version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not
|
||
* stale the audit (GST-391).
|
||
*/
|
||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, loopAccepted: readonly string[] | undefined, r: Report): boolean {
|
||
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
|
||
let stale: string[] | null = null;
|
||
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
|
||
if (stale === null) { r.stop(`gstack-docs-candidate compare failed: ${firstLine(cmp.stderr)}; documentation freshness is unknown (stage 3)`); return false; }
|
||
const record = JSON.parse(fs.readFileSync(postFile, 'utf8'));
|
||
const auditFile = path.join(cwd, '.gstack/tmp/docs-audit.json');
|
||
let docsAudit: ReceiptDocs | null = null;
|
||
if (fs.existsSync(auditFile)) {
|
||
let raw: unknown;
|
||
try { raw = JSON.parse(fs.readFileSync(auditFile, 'utf8')); } catch { raw = undefined; }
|
||
const parsed = parseReceiptDocs(raw);
|
||
if (!parsed.ok) { r.stop(`.gstack/tmp/docs-audit.json: ${parsed.message}; documentation status is unknown (stage 3)`); return false; }
|
||
docsAudit = parsed.docs;
|
||
}
|
||
const accepted = docsAudit?.exception;
|
||
if (accepted || record.exception) {
|
||
const same = !!accepted && !!record.exception && docsAudit!.status === 'blocked' && record.exception.scope === docsAudit!.scope
|
||
&& (['accepted_by', 'date', 'via', 'reason'] as const).every(k => record.exception[k] === accepted[k]);
|
||
if (!same) {
|
||
r.stop(accepted
|
||
? `the Step 14.5 audit is blocked and ${postFile} names no matching owner-accepted exception: snapshot with --exception .gstack/tmp/docs-audit.json (documentation.md Blocked recovery step 3)`
|
||
: `${postFile} names a documentation exception but .gstack/tmp/docs-audit.json is not the blocked record carrying it: rerun Step 14.5`);
|
||
return false;
|
||
}
|
||
}
|
||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||
const nul = (out: string) => out.split('\0').filter(Boolean);
|
||
const auditBase = git(cwd, ['merge-base', baseRef, record.head]).stdout.trim();
|
||
const nowBase = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
|
||
const atAudit = auditBase ? touchedPaths(cwd, auditBase, record.head) : new Set<string>();
|
||
const nowDiff = nowBase ? git(cwd, ['diff', '-z', '--name-only', '--no-renames', nowBase]) : null;
|
||
if (!atAudit || (nowDiff && nowDiff.status !== 0)) { r.stop(`git could not list the paths the branch touched against ${baseRef}; documentation freshness is unknown (stage 3)`); return false; }
|
||
const branchAtAudit = new Set([...atAudit, ...record.staged, ...record.unstaged, ...record.untracked]);
|
||
const branchNow = new Set([...(nowDiff ? nul(nowDiff.stdout) : []), ...nul(git(cwd, ['ls-files', '-z', '--others', '--exclude-standard']).stdout)]);
|
||
const changelog = shipPolicy(cwd, base)?.changelog;
|
||
const versionFile = versionAtRef(cwd, 'HEAD');
|
||
const restamped = stale.filter(p => {
|
||
const isChangelog = p === changelog;
|
||
if (!isChangelog && !('rel' in versionFile && p === versionFile.rel)) return false;
|
||
const audited = typeof record.hashes?.[p] === 'string' ? git(cwd, ['cat-file', 'blob', record.hashes[p]]) : null;
|
||
if (audited?.status !== 0) return false;
|
||
let now: string;
|
||
try { now = fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return false; }
|
||
if (!isChangelog) return !!parseVersion(audited.stdout.trim()) && !!parseVersion(now.trim());
|
||
const [atAuditBase, atNowBase] = [auditBase, nowBase].map(b => b ? git(cwd, ['show', `${b}:${p}`]) : null);
|
||
if (atAuditBase?.status !== 0 || atNowBase?.status !== 0) return false;
|
||
if (atAuditBase.stdout === atNowBase.stdout) return normalizeChangelog(audited.stdout) === normalizeChangelog(now);
|
||
const kept = mergeChangelogs(audited.stdout, atAuditBase.stdout, atAuditBase.stdout);
|
||
const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout);
|
||
return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now);
|
||
});
|
||
const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[], accepted: [] as string[] };
|
||
carried.accepted = stale.filter(p => loopAccepted?.includes(p) && !carried.tests.includes(p) && !carried.base.includes(p));
|
||
let audit: any = null;
|
||
try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; }
|
||
for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q) && !carried.accepted.includes(q))) {
|
||
const asked = [...(Array.isArray(audit?.blockers) ? audit.blockers : []), ...(Array.isArray(audit?.decisions) ? audit.decisions : [])].filter((s: unknown) => typeof s === 'string');
|
||
const token = new RegExp(`(^|[\\s\`'"(\\[])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s\`'")\\]]|[,.:;](?:$|\\s))`);
|
||
const stat = git(cwd, ['diff', '--numstat', '--no-renames', record.head, '--', `:(literal)${p}`]).stdout.trim().split(/\s+/);
|
||
const lines = Number(stat[0]) + Number(stat[1]);
|
||
const why = !audit ? 'needs --docs-result <the audit\'s saved result JSON>'
|
||
: audit.audit_id !== record.audit_id ? `is backed by the result of audit ${String(audit.audit_id)}, not ${String(record.audit_id)}`
|
||
: !asked.some((s: string) => token.test(s)) ? 'is not named in the audit result\'s blockers or decisions'
|
||
: classifyPath(p, cwd) !== 'docs' ? 'is not an authored doc'
|
||
: [...record.staged, ...record.unstaged, ...record.untracked].includes(p) ? 'was not clean at the post-audit snapshot'
|
||
: !(lines <= 5) ? `changed ${Number.isFinite(lines) ? lines : 'unknown'} lines since the audit (at most 5)` : null;
|
||
if (why) { r.stop(`--docs-requested ${p} ${why}: use stage 3 recovery`); return false; }
|
||
carried.requested.push(p);
|
||
}
|
||
if (carried.requested.length && bin(cwd, 'gstack-ship-receipt', ['docs-caps', '--base', base], 60_000).status !== 0) { r.stop('a --docs-requested fix breaks a doc cap (gstack-ship-receipt docs-caps): trim the file, then rerun finish'); return false; }
|
||
const left = stale.filter(p => !carried.tests.includes(p) && !carried.base.includes(p) && !carried.requested.includes(p) && !carried.accepted.includes(p));
|
||
const note = left.length || stale.length === 0 ? '' : ` (carried: tests=${carried.tests.length} base=${carried.base.length} requested=${carried.requested.length}${loopAccepted ? ` accepted=${carried.accepted.length}` : ''})`;
|
||
const exception = record.exception ? ` (blocked; documentation exception accepted by ${record.exception.accepted_by} ${record.exception.date} via ${record.exception.via})` : '';
|
||
r.add(`DOCS: ${left.length ? `stale (${short(left)})` : `fresh against the Step 14.5 post-audit snapshot${note}${exception}`}`);
|
||
if (left.length) { r.stop('documentation inputs changed since the accepted audit: use stage 3 recovery (the remaining re-audit, a fix the audit\'s result asked for via --docs-requested <path> --docs-result <audit-id>-result.json, or a named risk); never refresh hashes'); return false; }
|
||
return true;
|
||
}
|
||
|
||
export async function finish(argv: readonly string[], cwd: string): Promise<Report> {
|
||
const { flag, all, has } = flagReader(argv);
|
||
const base = flag('--base')!;
|
||
const r = new Report();
|
||
const list = lanes(argv) as { label: string; command: string }[];
|
||
let loop: ReviewLoopException | undefined;
|
||
const loopArg = flag('--review-loop-exception');
|
||
if (loopArg !== undefined) {
|
||
let raw: unknown;
|
||
try { raw = JSON.parse(loopArg.startsWith('@') ? fs.readFileSync(path.resolve(cwd, loopArg.slice(1)), 'utf8') : loopArg); } catch (e) { return r.stop(`--review-loop-exception: ${firstLine((e as Error).message)}`); }
|
||
const parsed = parseReviewLoopException(raw);
|
||
if (!parsed.ok) return r.stop(`--review-loop-exception: ${parsed.message}`);
|
||
if (Array.isArray((raw as { fix_check?: { files?: unknown } }).fix_check?.files) && !parsed.exception.fix_check.files) return r.stop('--review-loop-exception: fix_check.files lists no paths; omit it to accept the paths the branch touched at fix_check.head');
|
||
if (git(cwd, ['merge-base', '--is-ancestor', parsed.exception.fix_check.head, 'HEAD']).status !== 0) return r.stop(`--review-loop-exception: fix_check.head ${parsed.exception.fix_check.head.slice(0, 12)} is not HEAD or an ancestor of it`);
|
||
loop = parsed.exception;
|
||
}
|
||
const policy = shipPolicy(cwd, base);
|
||
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD', base), flag('--trailer'), r)) return r;
|
||
for (const cmd of all('--build')) {
|
||
const b = sh(cwd, cmd);
|
||
r.add(`BUILD: ${b.status === 0 ? 'ok' : `FAIL exit=${b.status}`} ${cmd.slice(0, 120)}`);
|
||
if (b.status !== 0) return r.stop(`Build failed or prerequisite missing: ${firstLine(b.stderr || b.stdout)}; repair it, then repeat stage 1`);
|
||
}
|
||
if (!all('--build').length) r.add('BUILD: none passed (record the inspected sources, or pass --build per declared command)');
|
||
const release = releasePaths(policy, cwd, flag('--reviewed-tree')!, base);
|
||
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop }, r);
|
||
if (!routed) return r;
|
||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, r)) return r;
|
||
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
|
||
const allow = evidenceExempt(release).join(',');
|
||
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
|
||
const mapped = mappedLanes(cwd, base, list);
|
||
const deferred: string[] = [];
|
||
for (const l of list) {
|
||
let e = check(l);
|
||
if (!/EVIDENCE: FRESH/.test(e)) {
|
||
const m = mapped.get(l.label);
|
||
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||
if (m && !res.carried) {
|
||
r.add(`EVIDENCE: DEFERRED label=${l.label} (${short([res.carryNone ?? 'not carried'], 1).slice(0, 160)}): CI at the pushed head carries it in accept`);
|
||
deferred.push(l.label);
|
||
continue;
|
||
}
|
||
const ran = laneLine(res);
|
||
r.add(ran.line);
|
||
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: STOP publication; run Steps 5-15 from Step 5's triage, then Step 16 stage 1`);
|
||
e = check(l);
|
||
}
|
||
const line = /^EVIDENCE: .*$/m.exec(e)?.[0] ?? `EVIDENCE: unreadable label=${l.label}`;
|
||
r.add(line.replace(/ log=\S+/, ''));
|
||
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH after one rerun: Step 16 stage 4 recovery`);
|
||
}
|
||
const laneArgs = list.map(l => `--lane '${l.label}=${l.command.replace(/'/g, `'\\''`)}'`).join(' ');
|
||
if (deferred.length) {
|
||
const out = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'gstack/pregate-finish-preflight.json']).stdout.trim() || '.git/gstack/pregate-finish-preflight.json');
|
||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--stage', 'preflight', '--out', out], 3_600_000);
|
||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||
r.add(`PREGATE: preflight ${verdict} on the stamped tree (the tests stage runs in accept, carried from CI)`);
|
||
if (!/^(pass|warn)/.test(verdict)) return r.stop(`pregate preflight ${verdict} on the stamped tree: fix it, then rerun finish`);
|
||
return r.next(`Step 16 stage 5, then Step 17's guard block and gstack-ship-release push; then gstack-ship-release accept --base ${base} ${laneArgs} carries ${deferred.join(', ')} from CI at the pushed head${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
|
||
}
|
||
const v = bin(cwd, 'gstack-pregate', ['verify'], 60_000);
|
||
if (v.status === 0) r.add('PREGATE: verify ok on the stamped tree');
|
||
else {
|
||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
|
||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
|
||
}
|
||
return r.next(`Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17's guard block and gstack-ship-release push${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
|
||
}
|
||
|
||
/**
|
||
* Final-tree CI acceptance (design fix A1): after push, each mapped lane is carried only from a green run at the pushed
|
||
* HEAD's exact, clean tree; the rest are reused or run; every lane must be FRESH, and the pregate reruns on that tree.
|
||
*/
|
||
export async function accept(argv: readonly string[], cwd: string): Promise<Report> {
|
||
const { flag } = flagReader(argv);
|
||
const base = flag('--base')!;
|
||
const list = lanes(argv) as { label: string; command: string }[];
|
||
const r = new Report();
|
||
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
|
||
if (!branch) return r.stop('detached HEAD: accept needs the feature branch');
|
||
const head = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
|
||
if (remote.status !== 0) return r.stop(`cannot read origin/${branch} (${firstLine(remote.stderr)}): restore access, then rerun accept`);
|
||
if (remote.stdout.split(/\s/)[0] !== head) return r.stop(`HEAD ${head.slice(0, 12)} is not origin/${branch}: run gstack-ship-release push first; accept carries only a run at the pushed head`);
|
||
const mapped = mappedLanes(cwd, base, list);
|
||
if (!mapped.size) return r.stop(`no --lane command is a key of origin/${base}'s .gstack/pregate.json ship_lanes_from: nothing comes from CI, so finish runs every lane here`);
|
||
const runCheck = buildRunCheck(cwd, null, true, base);
|
||
if ('error' in runCheck) return r.stop(`run profiles unavailable (${runCheck.error}): carry needs them; fix it, or run the lanes here with gstack-ship-preflight --tests-only`);
|
||
const waitS = Number(flag('--wait') ?? '170');
|
||
if (!Number.isFinite(waitS) || waitS < 0 || waitS > 3600) return r.stop(`--wait ${flag('--wait')} is not 0-3600 seconds`);
|
||
for (const name of [...new Set([...mapped.values()].flatMap(m => m.profiles))]) {
|
||
const p = runCheck.profiles[name];
|
||
if (!p) return r.stop(`ship_lanes_from names ${name}, which is not a run_profiles entry on origin/${base}`);
|
||
const w = await waitForRun(runCheck.deps, { workflow: p.workflow, events: p.events, head, timeoutMs: waitS * 1000, intervalMs: 15_000, sleep: ms => new Promise(res => setTimeout(res, ms)), clock: Date.now });
|
||
r.add(`CI_WAIT: ${w.state} ${name} ${'run' in w ? `run=${w.run.id}/${w.run.run_attempt}` : w.reason}`);
|
||
if (w.state === 'running') return r.stop(`${p.workflow} run ${w.run.id} at ${head.slice(0, 12)} is still running: rerun accept (or gstack-ci-carry wait --base ${base} in the background, then accept)`);
|
||
if (w.state === 'red') return r.stop(`${p.workflow} run ${w.run.id} failed (${short(w.failing, 4) || 'no failing job named'}): Step 5 triage on its logs; fix, commit, push, then accept again`);
|
||
if (w.state === 'none') return r.stop(`${w.reason}: push the branch (a pull_request or workflow_dispatch run must exist at HEAD), then accept again`);
|
||
}
|
||
for (const l of list) {
|
||
const m = mapped.get(l.label);
|
||
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
|
||
if (m && !res.carried) { r.add(carryNoneLine(res)!); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
|
||
const ran = laneLine(res);
|
||
r.add(ran.line);
|
||
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: Step 5 triage, then push and accept again`);
|
||
const e = bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24'], 60_000).stdout;
|
||
const line = /^EVIDENCE: .*$/m.exec(e)?.[0] ?? `EVIDENCE: unreadable label=${l.label}`;
|
||
r.add(line.replace(/ log=\S+/, ''));
|
||
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH at the pushed head: Step 16 stage 4 recovery`);
|
||
}
|
||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||
const carried = /^CI_CARRY: .*$/m.exec(pg.stdout + pg.stderr)?.[0];
|
||
if (carried) r.add(carried.length > 240 ? `${carried.slice(0, 240)}…` : carried);
|
||
r.add(`PREGATE: ${verdict} (final tree)`);
|
||
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the final tree: fix it, then rerun accept`);
|
||
return r.next('Step 18: the receipt records ci_carried from the ledger');
|
||
}
|
||
|
||
export function push(argv: readonly string[], cwd: string): Report {
|
||
const { flag, has } = flagReader(argv);
|
||
const r = new Report();
|
||
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
|
||
if (!branch) return r.stop('detached HEAD: push needs the feature branch');
|
||
if (branch !== flag('--branch')) return r.stop(`HEAD is on ${branch}, not the branch Step 1 saved (${flag('--branch')}): check out the saved branch, then rerun push`);
|
||
const guard = bin(cwd, 'gstack-config', ['get', 'redact_prepush_hook'], 30_000).stdout.trim() || 'false';
|
||
const prompted = fs.existsSync(path.join(resolveStateRoot(process.env), '.redact-prepush-prompted'));
|
||
const deferred = guard !== 'true' && !prompted && process.env.GSTACK_SESSION_KIND === 'unattended';
|
||
r.add(`REDACT_PREPUSH: ${guard} prompted=${prompted ? 'yes' : 'no'}${deferred ? ' deferred=unattended' : ''}`);
|
||
if (guard !== 'true' && !prompted && !deferred) return r.stop('Step 17\'s one-time pre-push guard offer is unanswered: run the guard block and ask it, then rerun push');
|
||
const gateOpen = Number(flag('--gate-open') ?? '0');
|
||
if (gateOpen > 0) {
|
||
if (!hostedOrigin(cwd)) return r.stop('open-PR lookup unavailable: origin is a local path, not a hosted remote gh can map; it must succeed before pushing open gate findings');
|
||
const pr = gh(cwd, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,isDraft', '--jq', '[.[] | select(.isDraft | not) | .number] | first // empty']);
|
||
if (pr.status !== 0) return r.stop(`open-PR lookup failed (${firstLine(pr.stderr)}): it must succeed before pushing open gate findings`);
|
||
if (pr.stdout.trim() && !has('--gate-open-approved')) return r.stop(`${gateOpen} open gate finding(s) and ready PR #${pr.stdout.trim()}: spawned/headless sessions stop here; otherwise ask (resolve now, or publish with the banner and rerun with --gate-open-approved)`);
|
||
}
|
||
const sealed = bin(cwd, 'gstack-pregate', ['sealed'], 120_000);
|
||
r.add(`SEALED: ${sealed.status === 0 ? 'clear' : sealed.status === 1 ? 'refused' : `not checked (exit ${sealed.status})`}`);
|
||
if (sealed.status === 1) return r.stop(`sealed output would be published: ${firstLine(sealed.stderr)}; remove it from the commits, then repeat Step 16`);
|
||
if (sealed.status !== 0) return r.stop(`the sealed-output guard did not run (exit ${sealed.status}: ${firstLine(sealed.stderr || sealed.stdout)}); repair it, then rerun push`);
|
||
if (has('--early')) {
|
||
const out = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'gstack/pregate-early-push.json']).stdout.trim() || '.git/gstack/pregate-early-push.json');
|
||
const scan = bin(cwd, 'gstack-pregate', ['--stage', 'preflight', '--only', 'secrets', '--out', out, ...(flag('--base') ? ['--base', flag('--base')!] : [])], 600_000);
|
||
const verdict = /^PREGATE: (.+?) tree=/m.exec(scan.stdout + scan.stderr)?.[1] ?? `unreadable (exit ${scan.status})`;
|
||
r.add(`SECRETS: ${verdict} (early push)`);
|
||
if (scan.status !== 0 || !/^(pass|warn)/.test(verdict)) return r.stop(`the secrets scan did not pass before the early push (${firstLine(scan.stderr) || verdict}): remove the finding from the commits, then rerun push --early`);
|
||
}
|
||
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
|
||
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
|
||
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
|
||
if (remote.stdout.split(/\s/)[0] === local) return r.add(`PUSH: ALREADY_PUSHED ${local.slice(0, 12)}`).next('Step 18');
|
||
const p = git(cwd, ['push', '-u', 'origin', branch], 600_000);
|
||
if (p.status !== 0) {
|
||
const nff = /non-fast-forward|fetch first|rejected/.test(p.stderr);
|
||
return r.stop(nff ? 'non-fast-forward: fetch and merge the remote under Step 3\'s conflict rules, run Steps 5-16, then push again; never force-push'
|
||
: `push failed (${firstLine(p.stderr)}): repair the auth, hook or network cause, repeat Step 16, then push; never bypass a guard`);
|
||
}
|
||
return r.add(`PUSH: pushed ${local.slice(0, 12)} to origin/${branch}`).next('Step 18');
|
||
}
|
||
|
||
export async function release(argv: readonly string[], cwd: string): Promise<{ text: string; exit: number }> {
|
||
const [cmd, ...rest] = argv;
|
||
const { flag, has } = flagReader(rest);
|
||
if (!cmd || cmd === '--help' || cmd === '-h' || has('--help')) return { text: RELEASE_HELP + '\n', exit: cmd ? 0 : 2 };
|
||
const usage = (m: string) => ({ text: `gstack-ship-release: ${m}\n`, exit: 2 });
|
||
if (cmd !== 'push' && !flag('--base')) return usage(`${cmd} needs --base <base>`);
|
||
if (cmd === 'push' && !flag('--branch')) return usage('push needs --branch <the branch Step 1 saved>');
|
||
if (cmd === 'accept' && !(lanes(rest) as unknown[]).length) return usage('accept needs at least one --lane <label>=<command> (the Step 5 lanes)');
|
||
if (typeof lanes(rest) === 'string') return usage(lanes(rest) as string);
|
||
const answer = flag('--answer');
|
||
if (has('--answer') && (!answer?.trim() || answer.startsWith('--') || /[\r\n]/.test(answer) || answer.length > 200)) return usage('--answer needs the owner\'s words: one non-empty line of at most 200 characters');
|
||
if (cmd === 'finish' && (!flag('--reviewed-tree') || !flag('--docs-post'))) return usage('finish needs --reviewed-tree <the tree saved before Step 12> and --docs-post <Step 14.5\'s post-audit snapshot>');
|
||
const r = cmd === 'version' ? version(rest, cwd) : cmd === 'finish' ? await finish(rest, cwd) : cmd === 'push' ? push(rest, cwd) : cmd === 'accept' ? await accept(rest, cwd) : null;
|
||
if (!r) return usage(`unknown command ${cmd}; run gstack-ship-release --help`);
|
||
return { text: r.render(), exit: r.exit };
|
||
}
|