mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready
gstack-pregate clear and gstack-tree-receipt now classify through one classifyFiles, so under changelog_fragments a receipt for the fragment head clears the stamped head when the stamp is exactly the fold. The ship-policy reference names ready's receipt push, land's own check polling, the key-off refusal and the /ship changelog_fragments line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ab72a9554a
commit
d095dc2c81
5 files changed
+70
-29
No files matched your search
@@ -21,7 +21,8 @@
|
||||
*
|
||||
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
|
||||
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
||||
* gstack-restamp stays the primitive. Nothing here pushes, except under a
|
||||
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
|
||||
* under changelog_fragments (its READY receipt note), and under a
|
||||
* ship policy with a `train` block: merge trains push train/<nonce> branches
|
||||
* and the refs/notes/gstack-trains records, and dispatch through eval-bg.
|
||||
* Logic: lib/ship-queue.ts, lib/ship-queue-gate.ts, lib/ship-queue-train*.ts
|
||||
@@ -66,7 +67,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen
|
||||
|
||||
--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
|
||||
refs/notes/gstack-land lock) and merge trains.
|
||||
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
|
||||
${exitTableHelp()}`;
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
|
||||
+14
-7
@@ -149,7 +149,8 @@ ship receipt into the coordinator protocol, so nobody proves a stamp diff by
|
||||
hand. Lanes own their PRs and never merge; the coordinator owns the order and
|
||||
every merge. Each step ends in a line the other side acts on, then
|
||||
`GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>`. Nothing
|
||||
here pushes.
|
||||
here pushes, except `ready` under `changelog_fragments` (its READY receipt goes
|
||||
to `refs/notes/gstack-trains`) and [`land`](#land).
|
||||
|
||||
**1. Lane: report ready (unstamped).** After the gate and pregate pass on the
|
||||
pushed head:
|
||||
@@ -784,6 +785,7 @@ which carry the workflow consequences the skill prose would otherwise spell out:
|
||||
| `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base <base>` must print only `ANCESTOR: ok` before push |
|
||||
| `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) |
|
||||
| `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) |
|
||||
| `ship: changelog_fragments <dir>: …` | Step 12 writes no version; Step 13 writes the entry as `<dir>/<branch-slug>.md` (entry text only, `bump:` front matter only when the change warrants more than the policy bump), never an `[Unreleased]` section, linted with `gstack-prose-lint <file> --gate`; `gstack-ship-queue land` assigns the version; see [changelog fragments](#changelog-fragments) |
|
||||
|
||||
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
|
||||
written by the pr-body section of `/ship` after the body is composed.
|
||||
@@ -1209,8 +1211,9 @@ valid fragment (a legacy `[Unreleased]`-only entry passes during migration;
|
||||
a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a
|
||||
dependency or a script, pass. A head made by the merge step, a commit whose
|
||||
parents are [pin, base], must carry the base's version plus the effective
|
||||
bump of the fragments its pin adds. `gstack-ship-queue ready` applies the same
|
||||
rule.
|
||||
bump of the fragments its pin adds. Under a train policy `gstack-ship-queue
|
||||
ready` applies the same rule itself; without one, `ready` requires the head's
|
||||
checks green, Version Gate included.
|
||||
|
||||
<a id="land"></a>
|
||||
## `gstack-ship-queue land`: the per-PR merge step
|
||||
@@ -1222,7 +1225,8 @@ stamp commit. Steps, each journaled in
|
||||
command:
|
||||
|
||||
1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a
|
||||
pin without a READY receipt (`gstack-ship-queue ready --pr <n>` records one
|
||||
base policy with `changelog_fragments` off (`QUEUE_NOT_READY`), a pin
|
||||
without a READY receipt (`gstack-ship-queue ready --pr <n>` records one
|
||||
on `refs/notes/gstack-trains` once its pregate, full_e2e and receipt checks
|
||||
pass; land checks nothing weaker), a /ship receipt that is missing or
|
||||
bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and
|
||||
@@ -1250,8 +1254,10 @@ command:
|
||||
keeps the reviewed pin apart from the last pushed head, so a rerun leases
|
||||
on what it pushed and never on a head someone else pushed
|
||||
(`QUEUE_TRAIN_MOVED`, never overwritten).
|
||||
5. **Wait** for the PR's checks on the stamp commit with `gstack-pr-wait`
|
||||
(`--no-wait` prints the command instead; `--timeout <min>`, default 60).
|
||||
5. **Wait** for the PR's checks on the stamp commit, polling every 30 seconds
|
||||
with the same check read the pin passed (`--timeout <min>`, default 60). `--no-wait`
|
||||
stops after the push and prints `gstack-pr-wait <n> --until green &&
|
||||
gstack-ship-queue land --pr <n>` to run instead.
|
||||
CI runs for real on the stamp commit in this mode.
|
||||
6. **Merge.** Re-read the base first: if it moved, restamp from the new base
|
||||
and push again under the last-pushed lease, at most twice
|
||||
@@ -1311,7 +1317,8 @@ Hunks are classified per file type: the version file (old → new only), the
|
||||
manifest (`version` key only), npm and bun lockfiles (root `version` keys
|
||||
only, where `gstack-version-bump` writes; a dependency entry is `changed`),
|
||||
the CHANGELOG (a heading move or re-heading, compared as an order-independent
|
||||
multiset of entries), `stamp_paths` and `release_outputs` (every
|
||||
multiset of entries; under `changelog_fragments`, also exactly the deleted
|
||||
fragments folded under the new heading, and those deletions are stamps), `stamp_paths` and `release_outputs` (every
|
||||
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with
|
||||
X the gated version, or a newer version of the repo's width that the
|
||||
stamp_paths stage moves (a tentative `*_KEY_VERSION` written as the next
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* `substituteStampOnlyTree` is the one accepted substitution. The gated tree
|
||||
* and the head tree are diffed path by path, and every differing file is
|
||||
* classified by the tree receipt's own rule (lib/tree-receipt.ts
|
||||
* `classifyFile`: the repo's `stamp_files` allowlist and `stamp_patterns`
|
||||
* `classifyFiles`, the changelog_fragments fold included: the repo's `stamp_files` allowlist and `stamp_patterns`
|
||||
* templates from `.gstack/ship-policy.json` on the base branch, beside the
|
||||
* built-in version-file, manifest-version, lockfile-root-version and
|
||||
* changelog-heading forms). The receipt is accepted for the head only when
|
||||
@@ -33,7 +33,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import type { PregateReceiptSubstitution } from '../headless-artifacts';
|
||||
import { parseUnifiedDiff, versionAtRef } from '../restamp';
|
||||
import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy';
|
||||
import { classifyFile } from '../tree-receipt';
|
||||
import { classifyFiles } from '../tree-receipt';
|
||||
import { treeMismatch } from './checks/lanes';
|
||||
|
||||
/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */
|
||||
@@ -103,7 +103,7 @@ export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTre
|
||||
if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
|
||||
const inventory = git(repoRoot, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', '--ignore-submodules=none', '--end-of-options', gatedTree, headTree]);
|
||||
if (inventory === null) return { problem: `${mismatch}; git diff-tree ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun };
|
||||
const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV));
|
||||
const files = classifyFiles(repoRoot, parseUnifiedDiff(diff), gatedTree, headTree, loaded.policy, versionRel, oldV, newV);
|
||||
if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun };
|
||||
const expected = new Set(inventory.split('\0').filter(Boolean));
|
||||
const seen = new Set(files.map(f => f.path));
|
||||
|
||||
+25
-17
@@ -50,6 +50,30 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file's verdict for one gated/current pair, with the changelog_fragments fold: a fragment deleted
|
||||
* between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new
|
||||
* heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree.
|
||||
*/
|
||||
export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] {
|
||||
const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
|
||||
const folded = new Map<string, Fragment>();
|
||||
for (const f of fileDiffs) {
|
||||
if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue;
|
||||
const text = blob(repoRoot, gated, f.path);
|
||||
const parsed = text === null ? null : parseFragment(f.path, text);
|
||||
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
|
||||
}
|
||||
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
|
||||
const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments);
|
||||
const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog);
|
||||
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
|
||||
return fileDiffs.map((f): FileVerdict => {
|
||||
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV);
|
||||
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every removed/added pair differs only by stamp rewrites to the new version:
|
||||
* the old version, or a tentative one (newer than it) the stamp_paths stage
|
||||
@@ -147,23 +171,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
|
||||
const oldV = 'version' in gv ? gv.version : null;
|
||||
const newV = 'version' in cv ? cv.version : null;
|
||||
const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? '';
|
||||
const fileDiffs = parseUnifiedDiff(diffText);
|
||||
const prefix = o.policy.changelog_fragments ? o.policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null;
|
||||
const folded = new Map<string, Fragment>();
|
||||
for (const f of fileDiffs) {
|
||||
if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue;
|
||||
const text = blob(o.repoRoot, gatedHead, f.path);
|
||||
const parsed = text === null ? null : parseFragment(f.path, text);
|
||||
if (parsed && !('code' in parsed)) folded.set(f.path, parsed);
|
||||
}
|
||||
const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1));
|
||||
const changelogVerdict = (f: FileDiff) => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV, fragments);
|
||||
const clDiff = fileDiffs.find(f => o.policy.changelog && f.path === o.policy.changelog);
|
||||
const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp';
|
||||
const files = fileDiffs.map((f): FileVerdict => {
|
||||
if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV);
|
||||
return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${o.policy.changelog ?? 'the CHANGELOG'}` };
|
||||
});
|
||||
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
|
||||
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
|
||||
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
|
||||
const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : '';
|
||||
|
||||
@@ -290,3 +290,28 @@ describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-o
|
||||
expect(pregateJson(repo).requires_remote[0].receipt).toBeNull();
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// /ship docs audit (GSTA-154 PR A): clear's substitution and gstack-tree-receipt share one classifier, fragment fold included.
|
||||
describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_fragments', () => {
|
||||
// Value: protects=a receipt for the fragment head clears the stamped head when the stamp is exactly the fold; fails_when=clear treats the fold as a change and forces a second remote gate, or accepts a fold that dropped the text; why_new=every other case uses [Unreleased]; seam=none; no_claim=restamp's own fold (its tests)
|
||||
test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => {
|
||||
const repo = makeFixtureRepo({
|
||||
pregate: { ...BASE_PREGATE, remote_gate: GATE },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' },
|
||||
});
|
||||
repos.push(repo);
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--ff-only', 'main');
|
||||
const gated = commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b;\n', 'changelog.d/add.md': '- add\n' }, 'change with a fragment');
|
||||
const gatedTree = repo.git('rev-parse', `${gated}^{tree}`);
|
||||
const stampTo = (entry: string) => {
|
||||
repo.git('checkout', '-q', '--detach', gated);
|
||||
fs.rmSync(path.join(repo.work, 'changelog.d', 'add.md'));
|
||||
return repo.git('rev-parse', `${commit(repo, { VERSION: '1.0.1\n', 'CHANGELOG.md': `# Changelog\n\n## [1.0.1] - 2026-10-29\n\n${entry}\n## [1.0.0] - 2026-10-01\n\n- base\n` }, 'v1.0.1 stamp')}^{tree}`);
|
||||
};
|
||||
const ok = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add\n'));
|
||||
expect('substitution' in ok && ok.substitution.files).toEqual(expect.arrayContaining([{ path: 'CHANGELOG.md', rule: 'changelog-fragments' }, { path: 'changelog.d/add.md', rule: 'changelog_fragments' }]));
|
||||
const edited = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add, reworded\n'));
|
||||
expect('problem' in edited && edited.problem).toContain('changelog.d/add.md (changelog_fragments: deleted without its text folded');
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in new issue
Block a user