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

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

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 05:45:17 +00:00
1 parent 381f7e8215
commit 23995aaeab
3 files changed
+14 -2

No files matched your search

+2 -1
View File
@@ -2,13 +2,14 @@
## [Unreleased]
**Four /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
**Five /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
### Fixed
- **`gstack-gate-diff <base>` in a clone with no local `<base>` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD.
- **`gstack-post` no longer flags the repository's own release versions as public IPs.** A four-part version is byte-for-byte a dotted quad, so "(1.91.191.0)" in a PR body raised `pii.ip_public` (MEDIUM) and stopped an unattended docs audit that cannot confirm a MEDIUM finding. The engine's version exemption only reads the declaration right before the span (`"version": "`, `## [`), so prose that merely names a release got no help. Now `lib/gstack-post.ts` reads the root `VERSION` value and every version in a root `CHANGELOG.md` release heading (`## [1.2.3.4]`, `## v1.2.3.4`, through the same `parseChangelog` gstack-restamp uses) and passes those exact strings to the scan's allowlist. Verdict: this narrows one detector by the repo's own release set, and nothing else. The allowlist is exact-string and context-free, so a released quad is exempt wherever it appears in the text, `host: 1.91.191.0` included; the set is the repo's own releases and nothing wider. `8.8.8.8` and `203.0.113.7` in prose, a quad the repo never released (`1.128.1.0`), and the same text outside a repository or in a repo whose VERSION is three-part all still need confirmation; `test/gstack-post.test.ts` carries those controls beside the four benign bodies (`(1.91.191.0)`, `v1.91.226.0`, a bare `1.91.191.0`, `release 1.91.190.0`) against a synthetic CHANGELOG in which `1.91.192.0` is not a release. The same post path also stops reading a ship receipt's all-digit tree hash (`tree=704992822996`) and an 11-digit GitHub Actions run id in prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`) as `pii.phone.e164`: only the exact tokens in those positions are allowlisted, and `+14155551234`, a bare 11-digit number and a 10-digit number after `run` still need confirmation (GSTA-94 hit this on #3424).
- **`gstack-evidence` records the test count of gstack's shard runner and of the remote pregate lane.** `bun run test:free` / `test:ubicloud` run quiet shards that forward bun's `Ran N tests` line without its ` N pass` counts, so the bun parser read every full-suite run as `tests_ran: 0` and /ship's Step 16 saw a zero-run suite (GSTA-94 on #3424); the remote lane prints `PREGATE_REMOTE: tree=… tests=9660 failed=0` (a lane row under `lanes:` in the pregate table) and `requires-remote: … cleared by remote-gate tree=… tests=9660`, which no parser knew, so it was `tests_ran: unknown` and the ship receipt and the Review Readiness Dashboard never carried the number. Now a bun terminal line with no counts block above it is not a bun summary (bun always prints ` 0 pass` / ` 0 fail`, even for an all-skip run, which still grades ZERO-RUN), the shard runner's per-shard `[test:free] PASS — N tests` epilogues are summed for the last run in the output (a files-from run's `FILES-FROM RESULT: … tests=N` is the same sum; a run with a `FAIL —` or `FLAKY-PASS —` epilogue and no such total has lost a shard's count and is recorded as unknown, not a partial sum), and when the shard runner spoke its verdict stands for the whole tail (the tail is the stdout tail then the stderr tail, so a line's position cannot order bun's stderr counts block against the runner's stdout epilogues; a focused `bun test` run before the suite in the same command no longer records its count, or ZERO-RUN, for the 10k-test run that followed), and the remote lane's two lines are parsed as whole lines like every other runner pattern (a prefixed or quoted echo does not count; the later line wins when both appear); a paid-only selection's `tests=0` is ZERO-RUN, not a pass. Honest limit: the shard runner and the remote gate print only bun's terminal totals, which include skipped tests, so for those two the number is tests ran with skips included, not executed (the shard runner refuses a shard whose bun summary counts fewer files than planned, but a shard whose files all loaded and executed nothing still passes with that total). The exit code is still never changed by the parse. `test/evidence.test.ts` runs the wrapper over `test/fixtures/evidence/pregate-remote-failed.out`, the stdout of a real `gstack-pregate --stage tests --remote` run captured under gstack-evidence on 2026-10-26 (9658 tests, 1 failed, recorded as `tests_ran=9658` with `exit=1`, never FRESH), over a cleared lane's table (`tests_ran=9660`, FRESH), over the receipt line alone, over the two lines with differing counts in both orders, and over `test/fixtures/evidence/shard-runner-files-from.out`, the remote gate's own shard-runner stream for this branch (275 files, 16 shards, 10138 tests: the epilogue sum equals the `FILES-FROM RESULT` total, with and without that final line, and a forwarded `Ran N tests` line on stderr no longer zeroes it).
- **`gstack-restamp` refuses only the CHANGELOG duplicates the PR introduces.** `CHANGELOG_DUPLICATE_HEADING` compared the branch's headings with themselves, so a base branch whose released history already heads one version twice (gbrain's `master` carries two `## [0.38.0.0]` sections, and its own check requires every one of them to stay) could never be stamped. The check now takes the base branch's CHANGELOG, read at `origin/<base>` and at the commit the branch's own lines are counted from (its fork point, or the predecessor's head in a gate-ahead stamp), the larger count per heading winning: a heading the base carries N times may appear N times on the PR, and only a further copy is refused, with a message that says how many the base has. When a reading is unavailable (the PR adds the CHANGELOG itself) the stage detail and the refusal name it. A duplicate the PR adds, on a clean base or beside an inherited one, is still refused with nothing written. Tests: a fixture whose `main` heads `1.2.2.0` twice stamps and keeps both copies, and still does after `main` collapsed the copies behind the PR's back; a PR that adds a third copy, one that adds the first duplicate on a clean base, and one whose new CHANGELOG on a base without one heads a version twice, are refused. `docs/troubleshooting.md` says which duplicates count.
- **`gstack-restamp --next` under a train no longer asks the lane to edit the CHANGELOG.** Its `next:` line told every stamp to "write the CHANGELOG entry text"; a lane that obeyed on #3422 added a release summary under the new heading, the train's verify refused the head (the CHANGELOG must equal the base's plus the PR's own entry re-headed) and the queue froze. Under `queue_mode stamp-at-merge` the line now says to commit and push only the stamp diff with no edit of the lane's own; the claim-at-open wording is unchanged, since that bump still writes its entry at /ship's CHANGELOG step. Test: the stamp-at-merge fixture's output carries no instruction to edit the CHANGELOG by hand, and the claim-at-open fixture still does.
## [1.91.226.0] - 2026-10-26
+1
View File
@@ -97,6 +97,7 @@ function report(r: RestampResult, json: boolean): number {
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`);
if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out <file>\` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`);
else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
else if (r.status === 'stamped' || r.status === 'noop') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; record the release decision, then write the CHANGELOG entry text only (the heading is already ${r.newVersion}); merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`);
}
process.stdout.write(formatResult('restamp', status, r.journal ?? JOURNAL_REL) + '\n');
+11 -1
View File
@@ -89,7 +89,13 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(a.status).toBe(EXIT.ok);
expect(a.out).toContain('ship, next in queue at 1.2.4.0');
expect(a.out).toContain('claims: ignored (stamp-at-merge)');
expect(a.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; .*CHANGELOG entry text only.*expected head [0-9a-f]{40}/m);
// Under a train the stamp commit may hold only restamp's own output: the next: line must not ask the lane
// to edit the CHANGELOG by hand (a lane that did was refused by the train's verify, freezing the queue).
// Value: protects=restamp's stamp-at-merge handoff never instructs a hand edit of CHANGELOG; fails_when=the
// claim-at-open wording ("write the CHANGELOG entry text") leaks into the train path; why_new=the earlier
// assertion pinned that wording as the contract; seam=none; no_claim=nothing about the lane obeying it.
expect(a.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; commit and push only the stamp diff above \(FILES\), with no edit of your own: .*expected head [0-9a-f]{40}/m);
expect(a.out).not.toMatch(/write the CHANGELOG|entry text|release summary/i);
expect(stages(a.out)).toMatchObject({ version: 'done', release_tool: 'done', changelog: 'done', stamp_paths: 'skipped', apply: 'done' });
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(readFile(repo, 'generated/digest.md')).toBe('# digest v1.2.4.0\nbody\n');
@@ -180,6 +186,10 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(r.out).not.toContain('ship, next in queue at');
const forced = restamp(repo, ['--next', '--dry-run', '--queue-mode', 'stamp-at-merge']);
expect(forced.out).toContain('claims: ignored (stamp-at-merge)');
// claim-at-open's bump still writes the entry text at /ship's CHANGELOG step; only the train path drops the ask.
const stamped = restamp(repo, ['--next']);
expect(stamped.status).toBe(EXIT.ok);
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
});
test('claim-at-open with an unknown claim says incomplete, names the PR and never says read', () => {