mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.92.63.0 fix(ship): a slot restamp after the docs audit keeps finish's docs check fresh (GST-906) (#3834)
* fix(ship): a slot restamp after the docs audit keeps finish's docs check fresh (GST-906) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the GST-906 restamp carry also holds after the slot merges main (red-team, ship round 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the GST-906 merge carry may only add the base's sections; an unmoved base keeps the direct rule (scoped fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog.d: GST-906 restamp carry fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
35817bae9a
commit
e657fa0b0d
6 files changed
+127
-5
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.63.0] - 2026-11-02
|
||||
|
||||
**A slot restamp after the docs audit no longer stops `finish` with `DOCS: stale (CHANGELOG.md)` (GST-906).** When a PR lost its merge slot after Step 14.5, the new slot's restamp re-heads the CHANGELOG entry and moves VERSION, and on the lost-slot path it merges main first. `gstack-ship-release finish` read all of that as a docs change, so the only ways forward were a third docs audit, which the policy forbids, or an owner-accepted named risk. Reported by GBRA-258 on gbrain #8372.
|
||||
|
||||
### Fixed
|
||||
- **Step 16's docs check carries a restamp.** The CHANGELOG counts as unchanged when it differs from the audited copy only by the heading move or re-heading. After a main merge, the audited copy is first merged with main's CHANGELOG by the same `mergeChangelogs` call the restamp writer makes, and that merge may only add main's sections. The version file counts as unchanged when it held a bare version at the audit and still does.
|
||||
- **Anything else still stales the audit.** That includes an entry edit, a dropped or edited main section, a dropped line the audit saw, and an audited CHANGELOG that git never held.
|
||||
|
||||
## [1.92.62.0] - 2026-11-02
|
||||
|
||||
- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.62.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.63.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+26
-2
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
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 { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
|
||||
@@ -217,7 +219,12 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
return by;
|
||||
}
|
||||
|
||||
/** Step 16 stage 3. Test code, paths only the base moved, and --docs-requested fixes the audit's own result asked for do not stale the audit (GST-391). */
|
||||
/**
|
||||
* 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, r: Report): boolean {
|
||||
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
|
||||
let stale: string[] | null = null;
|
||||
@@ -253,7 +260,24 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
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 carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] };
|
||||
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[] };
|
||||
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))) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.62",
|
||||
"version": "1.92.63",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -572,3 +572,93 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a CHANGELOG re-heading after the docs audit (GST-906, GBRA-258, gbrain #8372)', () => {
|
||||
// Value: protects=a slot restamp after the Step 14.5 snapshot that only re-heads the branch's CHANGELOG entry
|
||||
// ([Unreleased], or its old version after a lost slot, moved to `## [<version>] - <date>`, with the audited VERSION moved
|
||||
// in the second case) keeps the audit fresh, while an entry edit beside the re-heading, or audited bytes git does not
|
||||
// hold, stays stale; fails_when=finish stops with DOCS: stale (CHANGELOG.md) or (VERSION) on a pure restamp (recovery
|
||||
// then needs a third audit or a named risk) or waves an entry edit through; seam=lib/ship-release.ts docsFresh;
|
||||
// no_claim=the restamp writer
|
||||
test('a re-heading alone keeps the audit fresh; an entry edit or unheld audited bytes stays stale', () => {
|
||||
const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] });
|
||||
const log = (heading: string, entry = '- b ships') => `# Changelog\n\n${heading}\n\n${entry}\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n`;
|
||||
const cases = [
|
||||
['[Unreleased] re-headed', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), true, 'fresh'],
|
||||
['old version re-headed after a lost slot', '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), '1.0.2.0', log('## [1.0.2.0] - 2026-11-02'), true, 'fresh'],
|
||||
['re-heading plus an entry edit', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01', '- b ships fast'), true, 'stale'],
|
||||
['audited bytes never committed or staged', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), false, 'stale'],
|
||||
] as const;
|
||||
for (const [name, audited, auditedLog, stamped, stampedLog, committed, verdict] of cases) {
|
||||
const f = fx({ '.gstack/ship-policy.json': policy, 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n' });
|
||||
const reviewed = wtree(f);
|
||||
f.write({ VERSION: `${audited}\n`, 'CHANGELOG.md': auditedLog });
|
||||
if (committed) f.commit('Steps 12-13: version and CHANGELOG');
|
||||
const post = snapshot(f);
|
||||
f.write({ VERSION: `${stamped}\n`, 'CHANGELOG.md': stampedLog }); f.commit('slot restamp');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
|
||||
if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]);
|
||||
else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=the lost-slot restamp merges main first (CREW rule 6), so the CHANGELOG also gains main's new sections;
|
||||
// that merge plus the re-heading keeps the audit fresh, while a merge that drops or edits a main section, or hides an
|
||||
// entry edit, stays stale; fails_when=finish stops with DOCS: stale (CHANGELOG.md) after main merged a sibling's release,
|
||||
// or waves a changed section through; seam=lib/ship-release.ts docsFresh (mergeChangelogs as lib/restamp.ts calls it);
|
||||
// no_claim=the restamp writer
|
||||
test('a main merge before the re-heading keeps the audit fresh; a dropped or edited section stays stale', () => {
|
||||
const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] });
|
||||
const section = (heading: string, entry: string) => `${heading}\n\n${entry}\n\n`;
|
||||
const log = (...sections: string[]) => `# Changelog\n\n${sections.join('')}## [1.0.0.0] - 2026-10-30\n\n- base entry\n`;
|
||||
const own = section('## [1.0.1.0] - 2026-11-01', '- b ships');
|
||||
const sibling = section('## [1.0.1.0] - 2026-11-01', '- sibling ships');
|
||||
const cases = [
|
||||
['main merged, then re-headed', log(section('## [1.0.2.0] - 2026-11-02', '- b ships'), sibling), 'fresh'],
|
||||
['merge drops a main section', log(section('## [1.0.2.0] - 2026-11-02', '- b ships')), 'stale'],
|
||||
['merge edits a main section', log(section('## [1.0.2.0] - 2026-11-02', '- b ships'), section('## [1.0.1.0] - 2026-11-01', '- sibling ships fast')), 'stale'],
|
||||
['entry edit hidden inside a merge', log(section('## [1.0.2.0] - 2026-11-02', '- b ships fast'), sibling), 'stale'],
|
||||
] as const;
|
||||
for (const [name, stampedLog, verdict] of cases) {
|
||||
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'CHANGELOG.md': log() });
|
||||
const reviewed = wtree(f);
|
||||
f.write({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(own) }); f.commit('Steps 12-13: version and CHANGELOG');
|
||||
const post = snapshot(f);
|
||||
f.advanceBase({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(sibling) });
|
||||
mergeMain(f, { VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(own, sibling) });
|
||||
f.write({ VERSION: '1.0.2.0\n', 'CHANGELOG.md': stampedLog }); f.commit('slot restamp');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
|
||||
if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]);
|
||||
else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=the base merge in the carry may only add the base's sections: dropping a header line the audit saw
|
||||
// stays stale with or without a main merge, and a branch that also corrected a released entry keeps a pure re-heading
|
||||
// fresh; fails_when=mergeChangelogs' base-wins resolution hides a deleted audited line (DOCS: fresh) or stales a pure
|
||||
// re-heading; seam=lib/ship-release.ts docsFresh; no_claim=the restamp writer
|
||||
test('the base merge only adds base sections: a dropped audited line stays stale, a corrected released entry stays fresh', () => {
|
||||
const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] });
|
||||
const released = (entry = '- base entry') => `## [1.0.0.0] - 2026-10-30\n\n${entry}\n`;
|
||||
const log = (header: string, own: string, rest: string) => `# Changelog\n\n${header}${own}\n\n- b ships\n\n${rest}`;
|
||||
const sibling = '## [1.0.1.0] - 2026-11-01\n\n- sibling ships\n\n';
|
||||
const cases = [
|
||||
['audited header line dropped, no main merge', log('Notice: read this.\n\n', '## [1.0.1.0] - 2026-11-01', released()), false, log('', '## [1.0.2.0] - 2026-11-02', released()), 'stale'],
|
||||
['audited header line dropped in a main merge', log('Notice: read this.\n\n', '## [1.0.1.0] - 2026-11-01', released()), true, log('', '## [1.0.2.0] - 2026-11-02', sibling + released()), 'stale'],
|
||||
['released entry corrected, then re-headed', log('', '## [1.0.1.0] - 2026-11-01', released('- base entry, corrected')), false, log('', '## [1.0.2.0] - 2026-11-02', released('- base entry, corrected')), 'fresh'],
|
||||
] as const;
|
||||
for (const [name, auditedLog, merges, stampedLog, verdict] of cases) {
|
||||
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'CHANGELOG.md': `# Changelog\n\n${released()}` });
|
||||
const reviewed = wtree(f);
|
||||
f.write({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': auditedLog }); f.commit('Steps 12-13: version and CHANGELOG');
|
||||
const post = snapshot(f);
|
||||
if (merges) {
|
||||
f.advanceBase({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': `# Changelog\n\n${sibling}${released()}` });
|
||||
mergeMain(f, { VERSION: '1.0.1.0\n', 'CHANGELOG.md': stampedLog });
|
||||
}
|
||||
f.write({ VERSION: '1.0.2.0\n', 'CHANGELOG.md': stampedLog }); f.commit('slot restamp');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
|
||||
if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]);
|
||||
else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user