mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
v1.92.55.0 fix(ship): finish routes a restamped stale stamp as metadata after a lost slot (GST-895) (#3827)
* fix(ship): finish routes a restamped stale stamp on the branch's own line as a stamp after a lost slot (GST-895) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): one blank line between describe blocks (GST-895) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): a restamped line counts as the branch's own only by restamp's rule, and its moved version must be newer than the fork point (GST-895 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-895 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
e25997515c
commit
2512dd7624
7 files changed
+88
-11
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.55.0] - 2026-11-02
|
||||
|
||||
- **`/ship` finish no longer stops on a branch whose restamp moved its own stale stamp after a lost slot** (`gstack-ship-release finish`, GST-895). When a branch loses its merge slot, merges main and runs `gstack-restamp --next`, the restamp moves the branch's own `since:` rows from the lost version to the new one. Finish only accepted a moved version at or above the reviewed tree's VERSION, which now came from main, so it read those rows as a behavior change and stopped with "insert 5–11.5 → 12–14 → 16" (gbrain #8134 on 1.92.46.0). Now a line the branch added itself, by the same rule restamp uses (added since the merge base with main, and not added by main), may move any version newer than the branch's fork point, and finish routes it as release metadata. A row main carries, a row main edited after the merge, and a branch row at or before the fork point still stop. Only `stamp_files` paths count, and nothing else on the line may change.
|
||||
|
||||
### Tests
|
||||
- `test/ship-rereview-triggers.test.ts`: after a lost slot, a merge of main and a restamp, the branch's moved row routes as metadata; moving main's own row, a merged main row that main later edited, a branch row at the fork version, or one older than the fork point each stop. The first case fails on main with `behavior=1`.
|
||||
|
||||
## [1.92.54.0] - 2026-11-02
|
||||
|
||||
**`train land` and a single-PR `land` can no longer publish onto main at the same time, and a slot that forgot its version bump can no longer merge (#3599, #3531; GSTA-157).** Before, `train land` took no lock, so it and `land` could both read the same main and publish a combined tree neither tested. And train 33's `verify --train` said "may merge" for #3527's head after a bare commit dropped the stamp that restamp had written but not staged, so #3527 shipped under #3526's version.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.54.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.55.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,
|
||||
|
||||
+28
-5
@@ -10,7 +10,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fragmentSlug } from './changelog-fragments';
|
||||
import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp';
|
||||
import { branchAddedLines, forkVersion, parseUnifiedDiff, stampMentions, stampOnlyLine, versionAtRef } from './restamp';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { extractVersion } from './version-source';
|
||||
import { BIN_DIR, runBashSync, runScriptSync, scriptInvocation } from './script-invocation';
|
||||
@@ -96,19 +96,23 @@ export function versionOnlyEdit(cwd: string, ref: string, p: string): boolean {
|
||||
* never a behavior change. A manifest counts only while its edit is version-only (a dependency, script or
|
||||
* lockfile change is a build input); bun.lock never counts.
|
||||
*/
|
||||
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
|
||||
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
|
||||
const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
|
||||
const fragment = fragmentPath(policy, cwd);
|
||||
return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)),
|
||||
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths),
|
||||
...stampedFiles(policy, cwd, ref)])];
|
||||
...stampedFiles(policy, cwd, ref, base)])];
|
||||
}
|
||||
|
||||
/**
|
||||
* `stamp_files` paths whose working-tree edit against `ref` is a version stamp alone: every changed line swaps the
|
||||
* old version for the new one under a built-in form or a `stamp_patterns` template (lib/tree-receipt.ts classifyFile).
|
||||
* A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule:
|
||||
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
|
||||
* base its stale stamp is older than `ref`'s VERSION, so it must be newer than the fork point's version instead, the
|
||||
* bound gstack-restamp's stamp_paths stage uses (GST-895).
|
||||
*/
|
||||
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string): string[] {
|
||||
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
|
||||
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
|
||||
if (!globs.length) return [];
|
||||
const before = versionAtRef(cwd, ref);
|
||||
@@ -119,8 +123,27 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
|
||||
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
|
||||
if (diff.status !== 0) return [];
|
||||
let fork: string | undefined;
|
||||
let since: string | undefined;
|
||||
const own = new Map<string, Set<string>>();
|
||||
const ownLines = (rel: string): Set<string> => {
|
||||
if (own.has(rel)) return own.get(rel)!;
|
||||
since ??= git(cwd, ['merge-base', `origin/${base}`, 'HEAD']).stdout.trim();
|
||||
let lines = new Set<string>();
|
||||
if (since) {
|
||||
try { lines = branchAddedLines(cwd, since, [rel], ref, branchAddedLines(cwd, since, [rel], `origin/${base}`)).get(rel)?.text ?? lines; } catch {}
|
||||
}
|
||||
own.set(rel, lines);
|
||||
return lines;
|
||||
};
|
||||
const stamp = (rel: string, removed: string, added: string): boolean => {
|
||||
if (stampOnlyLine(removed, added, before.version, after, templates)) return true;
|
||||
if (!ownLines(rel).has(removed)) return false;
|
||||
fork ??= forkVersion(cwd, `origin/${base}`, before.version);
|
||||
return stampOnlyLine(removed, added, fork, after, templates) && (!stampMentions(removed, templates).has(fork) || stampMentions(added, templates).has(fork));
|
||||
};
|
||||
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
|
||||
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stampOnlyLine(line, h.added[i]!, before.version, after, templates)))).map(f => f.path);
|
||||
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
|
||||
}
|
||||
|
||||
/** Under `changelog_fragments`, the branch's own Step 13 entry `<dir>/<branch-slug>.md` (docs/ship-policy.md#changelog-fragments); null without the key. */
|
||||
|
||||
+3
-3
@@ -285,17 +285,17 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
|
||||
const r = new Report();
|
||||
const list = lanes(argv) as { label: string; command: string }[];
|
||||
const policy = shipPolicy(cwd, base);
|
||||
if (!commitRelease(cwd, releasePaths(policy, cwd, 'HEAD'), flag('--trailer'), r)) return r;
|
||||
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')!);
|
||||
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') }, r);
|
||||
if (!routed) return r;
|
||||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!)], all('--docs-requested'), flag('--docs-result'), r)) 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'), 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;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.54",
|
||||
"version": "1.92.55",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -202,6 +202,53 @@ describe('a version stamp after review (GBRA-251, gbrain #8095)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => {
|
||||
// Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older
|
||||
// than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the
|
||||
// base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row
|
||||
// at or older than the fork point, moved the same way still stops;
|
||||
// fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or
|
||||
// drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer
|
||||
const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] });
|
||||
const notice = (...rows: [string, string][]) => `export const NOTICES = [\n${rows.map(([v, text]) => ` { since: '${v}', text: '${text}' },`).join('\n')}\n];\n`;
|
||||
const lostSlot = (ownRow: string) => {
|
||||
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old']) },
|
||||
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], [ownRow, 'mine']) });
|
||||
f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs']) });
|
||||
mergeMain(f, { VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], [ownRow, 'mine']) });
|
||||
return { f, reviewed: wtree(f), reviewedBase: mergeBase(f), post: snapshot(f) };
|
||||
};
|
||||
const behaviorStop = 'STOP: behavior, tests or build inputs changed since review (src/notice.ts): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)';
|
||||
|
||||
test('the moved stale stamp is metadata; a moved base row or pre-fork row stops', () => {
|
||||
const own = lostSlot('1.0.1.0');
|
||||
own.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); own.f.commit('restamp 1.0.3.0');
|
||||
const ok = finish(own.f, own.reviewed, own.post, ['--reviewed-base', own.reviewedBase]);
|
||||
expect([lineOf(ok.stdout, 'ROUTE'), lineOf(ok.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]);
|
||||
|
||||
const base = lostSlot('1.0.1.0');
|
||||
base.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); base.f.commit('restamp plus the base row');
|
||||
const b = finish(base.f, base.reviewed, base.post, ['--reviewed-base', base.reviewedBase]);
|
||||
expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const old = lostSlot('0.9.5.0');
|
||||
old.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); old.f.commit('moves a pre-fork row');
|
||||
const o = finish(old.f, old.reviewed, old.post, ['--reviewed-base', old.reviewedBase]);
|
||||
expect([o.status, lineOf(o.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const atFork = lostSlot('1.0.0.0');
|
||||
atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version');
|
||||
const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]);
|
||||
expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
|
||||
const edited = lostSlot('1.0.1.0');
|
||||
edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin');
|
||||
edited.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); edited.f.commit('moves a merged base row the base later edited');
|
||||
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
|
||||
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
|
||||
// Value: protects=a base edit next to the branch's unchanged hunk asks for a scoped review of only those hunks (finish
|
||||
// context=N, carry RECEIPT_CARRY_SCOPED_REVIEW), and a clean scoped review lets it through; fails_when=it forces the
|
||||
|
||||
Reference in new issue
Block a user