mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
fix(ship-queue): train land takes the shared land lock (train-scoped, freeze-owned), verify --train proves the stamp and needs GitHub MERGED, restamp stages its writes (#3599, #3531)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
93fc5dc9e8
commit
60a6ecb85e
21 files changed
+1055
-150
No files matched your search
@@ -329,6 +329,9 @@ gstack_result_anchor() {
|
||||
RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;;
|
||||
CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;;
|
||||
QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;;
|
||||
QUEUE_TRAIN_UNSTAMPED) printf '%s' 'queue-train-unstamped' ;;
|
||||
RESTAMP_NOT_STAGED) printf '%s' 'restamp-not-staged' ;;
|
||||
RESTAMP_UNSTAGED_EDITS) printf '%s' 'restamp-unstaged-edits' ;;
|
||||
QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;;
|
||||
QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;;
|
||||
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
|
||||
|
||||
+63
-10
@@ -10,11 +10,18 @@
|
||||
* Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG
|
||||
* re-heading and the stamp_paths rewrites in an isolated staging worktree,
|
||||
* shows the diff limited to stamp files, and writes the branch only after
|
||||
* every stage validated (journal: .gstack/tmp/restamp-journal.json).
|
||||
* Idempotent; --dry-run writes nothing. Workflow command: ends with the
|
||||
* every stage validated (journal: .gstack/tmp/restamp-journal.json), then
|
||||
* stages what it wrote (`git add -A -- <FILES>`, STAGED:), so a bare commit after
|
||||
* a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding
|
||||
* unstaged edits of the caller's own refuses before anything is written
|
||||
* (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and
|
||||
* .gstack/tmp/restamp-staging.json, which the noop rerun stages
|
||||
* (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the
|
||||
* GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
|
||||
import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp';
|
||||
import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes';
|
||||
@@ -39,7 +46,7 @@ const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and
|
||||
--dry-run print the full diff, write nothing
|
||||
--json JSON only on stdout
|
||||
|
||||
Prints STAMP_VERSION: <X>, the stage table, the stamp diff and
|
||||
Prints STAMP_VERSION: <X>, the stage table, the stamp diff, STAGED: <files it staged> and
|
||||
GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}.
|
||||
${exitTableHelp()}`;
|
||||
|
||||
@@ -48,6 +55,44 @@ function flag(args: string[], name: string): string | undefined {
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
const STAGING_REL = '.gstack/tmp/restamp-staging.json';
|
||||
interface Pending { files: string[]; blobs: Record<string, string | null> }
|
||||
interface Staging { staged?: string[]; failure?: string }
|
||||
|
||||
const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
||||
/** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */
|
||||
function blobsOf(root: string, files: string[]): Record<string, string | null> {
|
||||
const present = files.filter(f => fs.existsSync(path.join(root, f)));
|
||||
const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : [];
|
||||
return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null]));
|
||||
}
|
||||
function readPending(root: string): Pending | null {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */
|
||||
function ownUnstaged(root: string): string[] {
|
||||
const pending = readPending(root);
|
||||
const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean);
|
||||
const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {};
|
||||
return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f]));
|
||||
}
|
||||
|
||||
/** Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; null when there is nothing to stage. */
|
||||
function stage(root: string, written: string[] | null): Staging | null {
|
||||
const marker = path.join(root, STAGING_REL);
|
||||
const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root);
|
||||
if (!pending?.files.length) return null;
|
||||
if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); }
|
||||
const now = written ? pending.blobs : blobsOf(root, pending.files);
|
||||
const changed = pending.files.filter(f => now[f] !== pending.blobs[f]);
|
||||
if (changed.length) return { failure: `${changed.join(', ')} changed since restamp wrote ${changed.length === 1 ? 'it' : 'them'}; nothing staged` };
|
||||
const add = gitIn(root, ['add', '-A', '--', ...pending.files]);
|
||||
if (add.status !== 0) return { failure: `git add of ${pending.files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` };
|
||||
fs.rmSync(marker, { force: true });
|
||||
return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...pending.files]).stdout.split('\0').filter(Boolean) };
|
||||
}
|
||||
|
||||
function main(argv: string[]): number {
|
||||
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
|
||||
const json = argv.includes('--json');
|
||||
@@ -65,23 +110,30 @@ function main(argv: string[]): number {
|
||||
const after = flag(argv, '--after');
|
||||
const base = resolveBaseBranch(repoRoot, flag(argv, '--base'));
|
||||
|
||||
const result = restamp({
|
||||
const options = {
|
||||
repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode,
|
||||
after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'),
|
||||
expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
|
||||
policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'),
|
||||
failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER,
|
||||
});
|
||||
return report(result, json);
|
||||
};
|
||||
const writes = !after && !options.dryRun;
|
||||
const own = writes ? ownUnstaged(repoRoot) : [];
|
||||
const clash = own.length ? (restamp({ ...options, dryRun: true }).files ?? []).filter(f => own.includes(f)) : [];
|
||||
if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json);
|
||||
const result = restamp(options);
|
||||
const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null;
|
||||
return report(result, json, staging);
|
||||
}
|
||||
|
||||
function report(r: RestampResult, json: boolean): number {
|
||||
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' ? 'incomplete' : 'complete';
|
||||
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' ? EXIT.fail : EXIT.ok;
|
||||
function report(r: RestampResult, json: boolean, staging: Staging | null = null): number {
|
||||
const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete';
|
||||
const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok;
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify({ ...r, result: status }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), result: status }) + '\n');
|
||||
return code;
|
||||
}
|
||||
if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`);
|
||||
if (r.code) {
|
||||
const row = RESULT_CODES[r.code as ResultCodeName];
|
||||
process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`);
|
||||
@@ -95,6 +147,7 @@ function report(r: RestampResult, json: boolean): number {
|
||||
if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`);
|
||||
if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n'));
|
||||
process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`);
|
||||
if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.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`);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* gstack-ship-queue gate-ahead --list --pr <n>
|
||||
* gstack-ship-queue verify --pr <n> --screen (train policy: the head's screen verdict from its run)
|
||||
* gstack-ship-queue verify --post-merge <sha> [--timeout <s>] (after a merge: main's push workflows at that commit until they settle)
|
||||
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts)
|
||||
* gstack-ship-queue land --pr <n> [--dry-run] [--no-wait] [--break-lock [--break-train-lock]] (the merge step; lib/ship-queue-land.ts)
|
||||
* gstack-ship-queue land --pr <n> --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR)
|
||||
* gstack-ship-queue land --census
|
||||
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
|
||||
@@ -56,7 +56,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT)
|
||||
verify --post-merge <sha> [--timeout <s>] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending
|
||||
and one CHECK: line per workflow; resumable, default timeout 2700 s
|
||||
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock] [--allow-repo-commands]
|
||||
land --pr <n> [--dry-run] [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]] [--allow-repo-commands]
|
||||
the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit,
|
||||
wait for its checks, squash with --match-head-commit, confirm the base tree; resumable
|
||||
land --pr <n> --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own
|
||||
@@ -168,7 +168,7 @@ async function main(argv: string[]): Promise<number> {
|
||||
if (args.includes('--census')) return finish('land', l.census(ctx));
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr <number> (or --census)');
|
||||
const timeout = flag(args, '--timeout');
|
||||
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
|
||||
return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), breakTrainLock: args.includes('--break-train-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined }));
|
||||
}
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
|
||||
const gateExit = flag(args, '--gate-exit');
|
||||
|
||||
@@ -12,10 +12,12 @@ Run: gstack-restamp --next --base <BASE> --pr <n> --expect-base <sha> --expect-o
|
||||
Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a
|
||||
CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the
|
||||
one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict
|
||||
markers before you stamp). Commit only the BASE merge and the stamp files this call names:
|
||||
<STAMP_FILES> (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own
|
||||
line only, when this call names it), never a fifth: a file the restamp lists that this call
|
||||
does not name is not a stamp, leave it out and say so.
|
||||
markers before you stamp). The restamp stages what it writes (STAGED:); commit what it
|
||||
staged, which must be only the BASE merge and the stamp files this call names: <STAMP_FILES>
|
||||
(plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own line only, when this
|
||||
call names it), never a fifth: a file the call does not name is not a stamp, `git restore
|
||||
--staged` it and say so. verify reads the head's own STAMP_VERSION and refuses an unstamped
|
||||
head (QUEUE_TRAIN_UNSTAMPED).
|
||||
A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's
|
||||
squash touched your lines: take your pin's bytes there (verify checks the tree against
|
||||
T<i>); mergeChangelogs carries your edits to the parent's released entry as Gate integrity
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
**A merge train 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.
|
||||
|
||||
### Fixed
|
||||
- **`train land` takes the shared land lock** (`refs/notes/gstack-land`, now in lib/ship-queue-land-lock.ts) as `train <nonce> slot <i> (#<pr>)`. Whichever of `land` and `train land` starts second refuses with `QUEUE_LAND_LOCKED`, naming the holder. A train keeps the lock in phase `between` from one merged slot to its next slot, so no single land slips in between slots and sets off a freeze. A stop that freezes the queue leaves the lock in phase `freeze`, which only the admitted repair train takes over.
|
||||
- **`--break-lock` prints the holder** (`LOCK_HOLDER:`), keeps land's staleness, mid-push and drift rules, never breaks a `freeze` lock, waits 10 minutes on a `between` lock, and refuses a live train run's lock unless `--break-train-lock` is also given. `train land` accepts both flags.
|
||||
- **`verify --train` checks the slot stamp itself**: the head's VERSION must be above main's and its CHANGELOG headed with that version directly above main's top release, or it refuses with the new `QUEUE_TRAIN_UNSTAMPED`, naming the rule and both versions. It prints `STAMP_VERSION:` from the head.
|
||||
- **`verify --train` calls a PR merged only when GitHub reports it MERGED.** An open release-only PR (#3530's shape) now gets the pre-merge check, and any other open or unreadable PR refuses with `QUEUE_PROOF_MISSING` without freezing the queue.
|
||||
- **`gstack-restamp` stages the files it writes** and prints `STAGED:`. It refuses before writing when a stamp file holds unstaged edits of your own (`RESTAMP_UNSTAGED_EDITS`); when staging fails, it says `RESTAMP_NOT_STAGED` and the next run stages the files.
|
||||
- Two comments in lib/ship-queue-train-gate.ts now say "one spend cap per command gate across every prefix of the train", which is what the code does.
|
||||
|
||||
### Tests
|
||||
- `test/ship-queue-land-lock.test.ts`: two writers racing one lock sha, the four lock read states, owner kinds, the `--break-lock` rules, and both lock directions, the between phase, refusal and resume, and the freeze lock's repair takeover through `bin/gstack-ship-queue`.
|
||||
- `test/ship-queue-train.test.ts`: the #3527 and #3530 shapes, the CHANGELOG rule, a claim-at-open pin, and no false freeze. `test/gstack-restamp-queue.test.ts`: staging after a no-commit merge, the unstaged-edits refusal, and a staging failure and its rerun.
|
||||
+41
-8
@@ -399,6 +399,23 @@ release paths (version files, CHANGELOG, fragments, `release_outputs`,
|
||||
train head and branch are S_k, and the one full gate runs on S_k, the exact
|
||||
tree the last merge publishes.
|
||||
|
||||
**One land lock.** `train land` takes the same repository land lock as
|
||||
[`land`](#land), `refs/notes/gstack-land`, as `train <nonce> slot <i> (#<pr>)`,
|
||||
before its first read of main, so a standalone land and a train slot never
|
||||
both publish onto one main: whichever starts second refuses with
|
||||
`QUEUE_LAND_LOCKED`, naming the holder. After a slot merges with landable slots
|
||||
left, the train keeps the lock in phase `between`, which only the same train's
|
||||
next slot takes over; it is released after the last landable slot, and on a
|
||||
refusal before any push. After a push, a failure keeps the lock in its phase
|
||||
and a rerun of the same slot takes it back. A stop that freezes the queue
|
||||
leaves the lock in phase `freeze`, which `--break-lock` never breaks: only the
|
||||
repair train the freeze admits takes it over, and it is released when that
|
||||
train clears the freeze. `--break-lock` prints the holder (`LOCK_HOLDER:`),
|
||||
keeps land's staleness, mid-push and drift rules, refuses a `between` lock
|
||||
younger than 10 minutes, and refuses a live train run's lock unless
|
||||
`--break-train-lock` is also given. `train watch --land` goes through `train
|
||||
land`, so it stops with `NEXT:` on `QUEUE_LAND_LOCKED` like any refusal.
|
||||
|
||||
Slots merge with `train land --train <nonce> --pr <n>`, never a slot call:
|
||||
|
||||
1. The record must be the gated head's own chain. Walking first parents from
|
||||
@@ -757,8 +774,8 @@ git fetch origin main
|
||||
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
|
||||
bun --no-env-file --config=/dev/null -e "import { mergeChangelogsWithNotes } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogsWithNotes(s(2), s(3), s(1)); for (const n of m.notes) console.error('CHANGELOG: carried as a Gate integrity correction: ' + n); if (!m.text) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m.text);"
|
||||
git add CHANGELOG.md && git commit --no-edit
|
||||
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
|
||||
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
|
||||
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands # stages what it writes (STAGED:)
|
||||
git commit -m "v<X> stamp" && git push
|
||||
```
|
||||
|
||||
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
|
||||
@@ -771,8 +788,12 @@ introduced). A line the base added since the pin's fork point is the base's,
|
||||
whether the base merge is committed or still staged (`stage stamp_paths` says
|
||||
"N line(s) the base added since the fork point are the base's"; train 24,
|
||||
2026-10-27, moved main's `DOCS_CAPS_KEY_VERSION` in both slots before this
|
||||
rule). A file the restamp lists that the slot call did not name is not a stamp:
|
||||
leave it out and report it. The tree receipt refuses such a move after the fact
|
||||
rule). Restamp stages what it writes and prints `STAGED:`; commit what restamp
|
||||
staged. A file the slot call does not name is not a stamp: `git restore --staged`
|
||||
it and report it. A stamp file holding unstaged edits of your own refuses before
|
||||
anything is written (`RESTAMP_UNSTAGED_EDITS`), and a staging failure leaves the
|
||||
files written for the noop rerun to stage (`RESTAMP_NOT_STAGED`), so a bare
|
||||
commit after a `--no-commit` base merge can no longer drop the stamp (#3527). The tree receipt refuses such a move after the fact
|
||||
only while the moved version is older than the base's at the build, so the
|
||||
restamp, not the receipt, is what keeps the base's key in place.
|
||||
|
||||
@@ -843,7 +864,16 @@ saw, itself the prefix before modulo stamps) plus the PR's own entry under the
|
||||
same rule. One CHANGELOG rule applies before and after, so a head the pre-merge
|
||||
verify accepted confirms after its squash merge with the same CHANGELOG, and a
|
||||
stacked child whose one `[Unreleased]` became two released sections is not a
|
||||
mismatch (train 24, 2026-10-27, froze on exactly that). Anything else is
|
||||
mismatch (train 24, 2026-10-27, froze on exactly that). Before
|
||||
`MERGE_CONDITION`, the head must also carry its own stamp: its `VERSION` above
|
||||
main's, and its CHANGELOG headed with that version directly above main's top
|
||||
release, with no `[Unreleased]` section main does not have; otherwise
|
||||
`QUEUE_TRAIN_UNSTAMPED` names the rule and both versions (train 33 merged #3527
|
||||
under #3526's version without it). verify prints `STAMP_VERSION:` from the head
|
||||
itself. A post-merge confirmation needs GitHub to report the PR MERGED: an open
|
||||
PR whose stamp-only diff already matches main (a release-only PR, #3530) takes
|
||||
the pre-merge path, and any other open or unreadable PR refuses
|
||||
`QUEUE_PROOF_MISSING` without freezing. Anything else is
|
||||
`QUEUE_TRAIN_TREE_MISMATCH` (or `QUEUE_TRAIN_MOVED`). When nothing of the train
|
||||
has merged, that is a plain refusal, and a new train is built on current main. When
|
||||
earlier slots already merged, main holds a prefix no gate covered on its own,
|
||||
@@ -1516,9 +1546,12 @@ command:
|
||||
2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own
|
||||
ref, never the trains notes ref), by exact lease. It records the PR, holder,
|
||||
phase and journal. A held lock is `QUEUE_LAND_LOCKED` naming the holder;
|
||||
`--break-lock` takes over a crashed holder's lock and is refused while that
|
||||
holder entered a push or a merge less than 10 minutes ago, and for a drift
|
||||
lock. A refusal before the push releases the lock.
|
||||
`--break-lock` prints the holder (`LOCK_HOLDER:`), takes over a crashed
|
||||
holder's lock and is refused while that holder entered a push or a merge
|
||||
less than 10 minutes ago, and for a drift lock. `train land` shares this
|
||||
lock ([one land lock](#train-land)); a train slot's lock needs
|
||||
`--break-train-lock` as well while its train still lands, and a `freeze`
|
||||
lock is never broken. A refusal before the push releases the lock.
|
||||
3. **Merge and stamp.** In a scratch worktree, merge the base into the pin.
|
||||
Conflicts in release fields only (the version file and `release_outputs`
|
||||
take the base's side, the CHANGELOG merges by section, a manifest or npm
|
||||
|
||||
+47
-6
@@ -4454,14 +4454,55 @@ the name already landed. Then rerun.
|
||||
<a id="queue-land-locked"></a>
|
||||
### `QUEUE_LAND_LOCKED`
|
||||
|
||||
**Meaning.** Another `gstack-ship-queue land` holds the repository land lock
|
||||
(`refs/notes/gstack-land`), or took it from under this run, so this one would
|
||||
race it for the next version. The line names the holder, its PR and phase.
|
||||
**Meaning.** Another `gstack-ship-queue land`, or a `train land` slot, holds the
|
||||
repository land lock (`refs/notes/gstack-land`), or took it from under this run,
|
||||
so this one would race it onto main. The line names the holder: `#<pr>` for a
|
||||
land, `train <nonce> slot <i> (#<pr>)` for a train, with its holder and phase. A
|
||||
train keeps the lock in phase `between` from one merged slot to the next, so no
|
||||
standalone land slips in between slots.
|
||||
|
||||
**Fix.** Wait for the holder to finish. If it crashed, rerun with
|
||||
`--break-lock`; that is refused while the holder entered a push or a merge
|
||||
less than 10 minutes ago. A lock in phase `drift` is released by landing its
|
||||
repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)).
|
||||
`--break-lock`, which prints the holder (`LOCK_HOLDER:`). That is refused while
|
||||
the holder entered a push or a merge less than 10 minutes ago, while a train is
|
||||
between slots for less than 10 minutes, and for a live train run unless you also
|
||||
pass `--break-train-lock`. A lock in phase `drift` is released by landing its
|
||||
repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)); a lock in phase `freeze` is
|
||||
released only by the repair train ([QUEUE_TRAIN_FROZEN](#queue-train-frozen)).
|
||||
|
||||
<a id="queue-train-unstamped"></a>
|
||||
### `QUEUE_TRAIN_UNSTAMPED`
|
||||
|
||||
**Meaning.** `verify --pr <n> --train <nonce>` found the slot head it would let
|
||||
merge without its own stamp: the head's `VERSION` is not above main's, or its
|
||||
CHANGELOG's top section is not headed with that version directly above main's
|
||||
top release (or keeps an `[Unreleased]` section main does not have). The line
|
||||
names the rule, the head's version and main's. Train 33 merged #3527 this way
|
||||
(a no-commit base merge, a restamp whose files were never staged, a bare
|
||||
commit) and shipped it under #3526's version. Nothing was merged.
|
||||
|
||||
**Fix.** On the lane, run `gstack-restamp --next` on the head, commit what
|
||||
restamp staged, push, and rerun the verify.
|
||||
|
||||
<a id="restamp-not-staged"></a>
|
||||
### `RESTAMP_NOT_STAGED`
|
||||
|
||||
**Meaning.** `gstack-restamp` wrote the stamp but `git add` failed, so a bare
|
||||
`git commit` would leave the stamp out. The files stay written, and
|
||||
`.gstack/tmp/restamp-staging.json` records what is still to stage.
|
||||
|
||||
**Fix.** Clear what blocked `git add` (a stale `.git/index.lock`, for
|
||||
example) and rerun `gstack-restamp`; the rerun is a noop that stages the files
|
||||
it wrote. A file changed since restamp wrote it is not staged; restamp again.
|
||||
|
||||
<a id="restamp-unstaged-edits"></a>
|
||||
### `RESTAMP_UNSTAGED_EDITS`
|
||||
|
||||
**Meaning.** A file `gstack-restamp` would stamp holds unstaged edits of yours.
|
||||
Restamp stages what it writes, so those edits would ride along in the stamp
|
||||
commit. Nothing was written.
|
||||
|
||||
**Fix.** Stage (`git add`) or stash your own edits to the named files, then
|
||||
rerun `gstack-restamp`.
|
||||
|
||||
<a id="queue-land-conflict"></a>
|
||||
### `QUEUE_LAND_CONFLICT`
|
||||
|
||||
@@ -377,8 +377,24 @@ export const RELEASE_RESULT_CODES = {
|
||||
},
|
||||
QUEUE_LAND_LOCKED: {
|
||||
anchor: 'queue-land-locked',
|
||||
summary: 'another gstack-ship-queue land holds the repository land lock (refs/notes/gstack-land), so this one would race it for the next version',
|
||||
fix: 'wait for the holder named in the line to finish; if it crashed, gstack-ship-queue land --pr <n> --break-lock (refused while its journal shows a push in flight)',
|
||||
summary: 'another gstack-ship-queue land or train land slot holds the repository land lock (refs/notes/gstack-land), so this one would race it onto main',
|
||||
fix: 'wait for the holder named in the line to finish; if it crashed, rerun with --break-lock (refused while it is mid-push or mid-merge, between train slots less than 10 minutes, and for a live train run without --break-train-lock)',
|
||||
},
|
||||
// #3531: verify --train's slot stamp proof and gstack-restamp's staging of what it writes.
|
||||
QUEUE_TRAIN_UNSTAMPED: {
|
||||
anchor: 'queue-train-unstamped',
|
||||
summary: 'the slot head verify --train would let merge is not stamped above main: its VERSION is not above main\'s, or its CHANGELOG is not headed with that version directly above main\'s top release',
|
||||
fix: 'on the lane, run gstack-restamp --next on the head, commit what restamp staged, push, then rerun gstack-ship-queue verify --pr <n> --train <nonce>',
|
||||
},
|
||||
RESTAMP_NOT_STAGED: {
|
||||
anchor: 'restamp-not-staged',
|
||||
summary: 'gstack-restamp wrote the stamp but could not stage it, so a bare git commit would leave the stamp out',
|
||||
fix: 'clear what blocked git add (an index.lock, a changed file), then rerun gstack-restamp: the noop rerun stages the files it wrote',
|
||||
},
|
||||
RESTAMP_UNSTAGED_EDITS: {
|
||||
anchor: 'restamp-unstaged-edits',
|
||||
summary: 'a file gstack-restamp would stamp holds unstaged edits of the caller, which staging the stamp would sweep into the stamp commit; nothing was written',
|
||||
fix: 'stage (git add) or stash your own edits to the files named, then rerun gstack-restamp',
|
||||
},
|
||||
QUEUE_LAND_CONFLICT: {
|
||||
anchor: 'queue-land-conflict',
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The repository land lock (docs/ship-policy.md#land, #3599): one ref, refs/notes/gstack-land, whose commit
|
||||
* holds lock.json, shared by `gstack-ship-queue land` (owner `{ pr }`) and `train land` (owner
|
||||
* `{ train, slot, pr }`), so a standalone land and a train slot never both publish onto one main. Every write
|
||||
* is an exact lease on the sha read, so two writers racing one sha leave one winner. A lock is resumed only by
|
||||
* the same owner kind, identity and journal; anything else is a takeover the caller allows (a drift repair, a
|
||||
* train's next slot, the admitted repair train) or a --break-lock under the staleness, mid-push, drift, freeze
|
||||
* and live-train rules.
|
||||
*/
|
||||
import { git, type QueueFailure } from './ship-queue-gate';
|
||||
|
||||
export const LAND_LOCK_REF = 'refs/notes/gstack-land';
|
||||
export const PUSH_STALE_MS = 10 * 60_000;
|
||||
export type LandLockPhase = 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift' | 'between' | 'freeze';
|
||||
export interface LandLock { pr: string; holder: string; started_at: string; phase: LandLockPhase; journal: string; phase_at?: string; repair_pr?: string; drift_of?: string; train?: string; slot?: number }
|
||||
export type LockOwner = { pr: string; train?: undefined; slot?: undefined } | { pr: string; train: string; slot: number };
|
||||
export type LockRead = { state: 'absent'; sha: '' } | { state: 'released'; sha: string } | { state: 'held'; sha: string; lock: LandLock } | { state: 'unreadable'; sha: string; detail: string };
|
||||
type Released = { released: true; by: string; at: string };
|
||||
|
||||
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
|
||||
/** How a lock names its owner in every message: `#<pr>`, or `train <nonce> slot <i> (#<pr>)`. */
|
||||
export const lockLabel = (lock: LandLock): string => (lock.train ? `train ${lock.train} slot ${lock.slot} (#${lock.pr})` : `#${lock.pr}`);
|
||||
/** The same owner kind, identity and journal: a resume of this run's journal, never just the same PR. */
|
||||
export const ownsLock = (lock: LandLock, owner: LockOwner, journal: string): boolean =>
|
||||
lock.pr === owner.pr && (lock.train ?? null) === (owner.train ?? null) && (lock.slot ?? null) === (owner.slot ?? null) && lock.journal === journal;
|
||||
|
||||
export function readLock(root: string, remote = 'origin'): LockRead {
|
||||
git(root, ['fetch', '-q', remote, `+${LAND_LOCK_REF}:${LAND_LOCK_REF}`]);
|
||||
const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out;
|
||||
if (!sha) return { state: 'absent', sha: '' };
|
||||
const body = git(root, ['show', `${sha}:lock.json`]);
|
||||
if (!body.ok) return { state: 'unreadable', sha, detail: 'no lock.json' };
|
||||
let value: Partial<LandLock> & { released?: boolean };
|
||||
try { value = JSON.parse(body.out); } catch { return { state: 'unreadable', sha, detail: 'lock.json is not JSON' }; }
|
||||
if (value?.released === true) return { state: 'released', sha };
|
||||
if (!/^\d+$/.test(String(value?.pr)) || typeof value.phase !== 'string' || typeof value.holder !== 'string') return { state: 'unreadable', sha, detail: 'lock.json names no PR, holder and phase' };
|
||||
return { state: 'held', sha, lock: value as LandLock };
|
||||
}
|
||||
|
||||
export function writeLock(root: string, expected: string, value: LandLock | Released, remote = 'origin'): { ok: boolean; sha: string } {
|
||||
const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out;
|
||||
const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out;
|
||||
const message = 'released' in value ? `land lock released by ${value.by}` : `land lock ${value.train ? `train ${value.train} slot ${value.slot} ` : ''}#${value.pr} ${value.phase}`;
|
||||
const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', message]).out;
|
||||
if (![blob, tree, commit].every(isSha)) return { ok: false, sha: '' };
|
||||
const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, remote, `${commit}:${LAND_LOCK_REF}`]);
|
||||
if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]);
|
||||
return { ok: push.ok, sha: commit };
|
||||
}
|
||||
|
||||
export interface HeldLandLock {
|
||||
/** The lock as this run last wrote it. */
|
||||
readonly lock: LandLock;
|
||||
/** This owner's lock as it was before this run took it back (a resume), else null. */
|
||||
readonly resumed: LandLock | null;
|
||||
/** Move to a phase under the exact lease; a failure means another lander took the lock. */
|
||||
set(phase: LandLockPhase, extra?: Partial<LandLock>): QueueFailure | null;
|
||||
release(): boolean;
|
||||
}
|
||||
export interface AcquireOptions {
|
||||
root: string; remote?: string; holder: string; owner: LockOwner; journal: string; now: () => Date;
|
||||
/** The first phase, given this owner's own lock when this run resumes it. */
|
||||
phase: (own: LandLock | null) => LandLockPhase;
|
||||
breakLock?: boolean; breakTrainLock?: boolean;
|
||||
/** A takeover the caller allows (a drift repair, a train's next slot, the admitted repair train): its LOCK: line, else null. */
|
||||
takeover?: (lock: LandLock) => string | null;
|
||||
/** Whether a train still lands (its record is not terminal); unknown counts as live. */
|
||||
liveTrain?: (nonce: string) => boolean;
|
||||
/** Fields every write of this run carries (land's drift_of), given this owner's own lock when resumed. */
|
||||
extra?: (own: LandLock | null) => Partial<LandLock>;
|
||||
}
|
||||
|
||||
/** Why --break-lock may not break a foreign lock, or null when it may. */
|
||||
function unbreakable(lock: LandLock, o: AcquireOptions, age: number): QueueFailure | null {
|
||||
const label = lockLabel(lock);
|
||||
if (lock.phase === 'drift' || lock.drift_of) return { code: 'QUEUE_LAND_DRIFT', message: `#${lock.drift_of ?? lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.drift_of ?? lock.pr} --repair` };
|
||||
if (lock.phase === 'freeze') return { code: 'QUEUE_TRAIN_FROZEN', message: `${label} holds the land lock for a frozen queue; --break-lock never breaks it, only the repair train the freeze admits takes it over` };
|
||||
if (lock.train && (o.liveTrain?.(lock.train) ?? true) && !o.breakTrainLock) return { code: 'QUEUE_LAND_LOCKED', message: `${label} is a live train run's lock (phase ${lock.phase}); --break-lock refused without --break-train-lock` };
|
||||
if ((lock.phase === 'pushing' || lock.phase === 'merging') && age < PUSH_STALE_MS) return { code: 'QUEUE_LAND_LOCKED', message: `${label}'s holder is mid-${lock.phase === 'pushing' ? 'push' : 'merge'} (${Math.round(age / 1000)} s ago); --break-lock refused` };
|
||||
if (lock.phase === 'between' && age < PUSH_STALE_MS) return { code: 'QUEUE_LAND_LOCKED', message: `${label} is between slots (${Math.round(age / 1000)} s ago); --break-lock refused until it is ${PUSH_STALE_MS / 60_000} minutes old` };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the land lock for `owner`: resume this owner's own lock, take over one the caller allows, or (with
|
||||
* --break-lock, which always names the holder) break a foreign one the rules permit; then write the first
|
||||
* phase under the exact lease on the sha read.
|
||||
*/
|
||||
export function acquireLandLock(o: AcquireOptions): { held: HeldLandLock; lines: string[] } | { failure: QueueFailure; lines: string[] } {
|
||||
const remote = o.remote ?? 'origin';
|
||||
const lines: string[] = [];
|
||||
const read = readLock(o.root, remote);
|
||||
let sha = read.sha;
|
||||
let current: LandLock | null = read.state === 'held' ? read.lock : null;
|
||||
const own = current && ownsLock(current, o.owner, o.journal) ? current : null;
|
||||
if (read.state === 'unreadable') {
|
||||
if (!o.breakLock) return { failure: { code: 'QUEUE_LAND_LOCKED', message: `the land lock at ${read.sha.slice(0, 12)} cannot be read (${read.detail}); --break-lock replaces it` }, lines };
|
||||
lines.push(`LOCK: broke an unreadable lock at ${read.sha.slice(0, 12)} (${read.detail})`);
|
||||
} else if (current && !own) {
|
||||
const allowed = o.takeover?.(current) ?? null;
|
||||
if (allowed) lines.push(allowed);
|
||||
else {
|
||||
const age = o.now().getTime() - Date.parse(current.phase_at ?? current.started_at);
|
||||
if (!o.breakLock) return { failure: { code: 'QUEUE_LAND_LOCKED', message: `${lockLabel(current)} holds the land lock (${current.holder}, phase ${current.phase}, since ${current.started_at})` }, lines };
|
||||
lines.push(`LOCK_HOLDER: ${lockLabel(current)} holder=${current.holder} phase=${current.phase} since=${current.started_at}`);
|
||||
const refusal = unbreakable(current, o, age);
|
||||
if (refusal) return { failure: refusal, lines };
|
||||
lines.push(`LOCK: broke ${lockLabel(current)}'s lock (${current.holder}, phase ${current.phase})`);
|
||||
}
|
||||
}
|
||||
const startedAt = own?.started_at ?? o.now().toISOString();
|
||||
const always = o.extra?.(own) ?? {};
|
||||
const set = (phase: LandLockPhase, extra: Partial<LandLock> = {}): QueueFailure | null => {
|
||||
const next: LandLock = { pr: o.owner.pr, ...(o.owner.train ? { train: o.owner.train, slot: o.owner.slot } : {}), holder: o.holder, started_at: startedAt, phase, journal: o.journal, phase_at: o.now().toISOString(), ...always, ...extra };
|
||||
const w = writeLock(o.root, sha, next, remote);
|
||||
if (!w.ok) return { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' };
|
||||
sha = w.sha; current = next;
|
||||
return null;
|
||||
};
|
||||
const failed = set(o.phase(own));
|
||||
if (failed) return { failure: failed, lines };
|
||||
const held: HeldLandLock = {
|
||||
get lock() { return current!; },
|
||||
resumed: own,
|
||||
set,
|
||||
release: () => writeLock(o.root, sha, { released: true, by: o.holder, at: o.now().toISOString() }, remote).ok,
|
||||
};
|
||||
return { held, lines };
|
||||
}
|
||||
+29
-52
@@ -17,15 +17,15 @@ import * as path from 'node:path';
|
||||
import { mergeChangelogs, parseChangelog } from './changelog-check';
|
||||
import { fragmentSlug, readFragments } from './changelog-fragments';
|
||||
import { restamp, versionAtRef } from './restamp';
|
||||
import { acquireLandLock, readLock, writeLock, type LandLockPhase } from './ship-queue-land-lock';
|
||||
import { matchesAny, type ShipPolicy } from './ship-policy';
|
||||
import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue';
|
||||
import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate';
|
||||
import { bindShippedReceipt } from './ship-receipt-depth';
|
||||
import { loadReady, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
|
||||
import { TERMINAL_STATES, loadReady, loadTrain, rebuildFromNotes, repoIdentity } from './ship-queue-train-record';
|
||||
|
||||
export const LAND_LOCK_REF = 'refs/notes/gstack-land';
|
||||
export { LAND_LOCK_REF } from './ship-queue-land-lock';
|
||||
export const MAX_RESTAMPS = 2;
|
||||
const PUSH_STALE_MS = 10 * 60_000;
|
||||
const MANIFESTS = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json'];
|
||||
const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? '');
|
||||
/** The first path segment of `rel` inside `wt` that is a symlink, or null: land writes only inside the checkout. */
|
||||
@@ -49,35 +49,13 @@ export interface LandJournal {
|
||||
drift_main?: string; repair_pr?: string;
|
||||
phase: 'locked' | 'stamped' | 'pushed' | 'merged' | 'landed' | 'drift';
|
||||
}
|
||||
export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string; phase_at?: string; repair_pr?: string; drift_of?: string }
|
||||
export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string }
|
||||
export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; breakTrainLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string }
|
||||
|
||||
const fail = (lines: string[], failure: QueueFailure, run: string): StepOutput => ({ ok: false, lines, failure, run });
|
||||
export const landJournalRel = (pr: string) => `${QUEUE_DIR_REL}/land/${pr}.json`;
|
||||
|
||||
function readJson<T>(file: string): T | null { try { return JSON.parse(fs.readFileSync(file, 'utf8')) as T; } catch { return null; } }
|
||||
|
||||
/** The remote land lock: a ref whose commit holds lock.json, or a released marker. Updated only by exact lease. */
|
||||
function readLock(root: string): { sha: string; lock: LandLock | null } {
|
||||
git(root, ['fetch', '-q', 'origin', `+${LAND_LOCK_REF}:${LAND_LOCK_REF}`]);
|
||||
const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out;
|
||||
if (!sha) return { sha: '', lock: null };
|
||||
const body = git(root, ['show', `${sha}:lock.json`]);
|
||||
let lock: LandLock | { released: true } | null = null;
|
||||
try { lock = body.ok ? JSON.parse(body.out) : null; } catch { lock = null; }
|
||||
return { sha, lock: lock && 'pr' in lock && /^\d+$/.test(String(lock.pr)) ? lock : null };
|
||||
}
|
||||
|
||||
function writeLock(root: string, expected: string, value: LandLock | { released: true; by: string; at: string }): { ok: boolean; sha: string } {
|
||||
const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out;
|
||||
const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out;
|
||||
const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', 'released' in value ? `land lock released by ${value.by}` : `land lock #${value.pr} ${value.phase}`]).out;
|
||||
if (![blob, tree, commit].every(isSha)) return { ok: false, sha: '' };
|
||||
const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, 'origin', `${commit}:${LAND_LOCK_REF}`]);
|
||||
if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]);
|
||||
return { ok: push.ok, sha: commit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Release-version conflicts only: the version file and release outputs take the base's side, the
|
||||
* CHANGELOG merges by section (mergeChangelogs), and a manifest or npm lockfile is re-merged with
|
||||
@@ -328,7 +306,7 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi
|
||||
if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run);
|
||||
j.repair_pr = n; writeJsonAtomic(file, j);
|
||||
const held = readLock(root);
|
||||
if (held.lock?.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n });
|
||||
if (held.state === 'held' && !held.lock.train && held.lock.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n });
|
||||
return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${pushed}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] };
|
||||
} finally {
|
||||
git(root, ['worktree', 'remove', '--force', wt]);
|
||||
@@ -379,28 +357,27 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
|
||||
return { ok: true, run, lines: [...lines, ...s.lines, `STAMP_VERSION: ${s.version}`, `STAMP_TREE: ${s.tree}`, 'DRY-RUN: nothing locked, pushed or merged'] };
|
||||
}
|
||||
|
||||
let { sha: lockSha, lock } = readLock(root);
|
||||
const drifted = lock && lock.pr !== o.pr && lock.phase === 'drift' ? lock.pr : null;
|
||||
const repairOf = drifted ? readJson<LandJournal>(path.join(root, landJournalRel(drifted))) : null;
|
||||
const driftOf = drifted && (lock!.repair_pr === o.pr || repairOf?.repair_pr === o.pr) ? drifted : lock?.pr === o.pr ? lock.drift_of : undefined;
|
||||
if (drifted && driftOf) lines.push(`LOCK: taking over #${drifted}'s drift lock for its repair`);
|
||||
else if (lock && lock.pr !== o.pr) {
|
||||
const age = now().getTime() - Date.parse(lock.phase_at ?? lock.started_at);
|
||||
if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run);
|
||||
if (lock.phase === 'drift' || lock.drift_of) return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: `#${lock.drift_of ?? lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.drift_of ?? lock.pr} --repair` }, run);
|
||||
if ((lock.phase === 'pushing' || lock.phase === 'merging') && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-${lock.phase === 'pushing' ? 'push' : 'merge'} (${Math.round(age / 1000)} s ago); --break-lock refused` }, run);
|
||||
lines.push(`LOCK: broke #${lock.pr}'s lock (${lock.holder}, phase ${lock.phase})`);
|
||||
}
|
||||
const setLock = (phase: LandLock['phase']): QueueFailure | null => {
|
||||
const next: LandLock = { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run, phase_at: now().toISOString(), ...(driftOf ? { drift_of: driftOf } : {}) };
|
||||
const w = writeLock(root, lockSha, next);
|
||||
if (!w.ok) return { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' };
|
||||
lockSha = w.sha; lock = next;
|
||||
return null;
|
||||
};
|
||||
let driftOf: string | undefined;
|
||||
const got = acquireLandLock({
|
||||
root, holder, owner: { pr: o.pr }, journal: run, now, breakLock: o.breakLock, breakTrainLock: o.breakTrainLock,
|
||||
takeover: held => {
|
||||
if (held.train || held.phase !== 'drift' || (held.repair_pr !== o.pr && readJson<LandJournal>(path.join(root, landJournalRel(held.pr)))?.repair_pr !== o.pr)) return null;
|
||||
driftOf = held.pr;
|
||||
return `LOCK: taking over #${held.pr}'s drift lock for its repair`;
|
||||
},
|
||||
liveTrain: nonce => {
|
||||
if (!loadTrain(nonce)) rebuildFromNotes(root, ctx.remote ?? 'origin');
|
||||
const t = loadTrain(nonce);
|
||||
return !t || !TERMINAL_STATES.includes(t.state);
|
||||
},
|
||||
extra: own => { driftOf ??= own?.drift_of; return driftOf ? { drift_of: driftOf } : {}; },
|
||||
phase: () => (j?.phase === 'pushed' ? 'waiting' : j?.phase === 'merged' ? 'merging' : 'stamping'),
|
||||
});
|
||||
lines.push(...got.lines);
|
||||
if ('failure' in got) return fail(lines, got.failure, run);
|
||||
const lock = got.held;
|
||||
const setLock = (phase: LandLockPhase) => lock.set(phase);
|
||||
const save = () => writeJsonAtomic(file, j);
|
||||
const failed = setLock(j?.phase === 'pushed' ? 'waiting' : j?.phase === 'merged' ? 'merging' : 'stamping');
|
||||
if (failed) return fail(lines, failed, run);
|
||||
if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: recovered ? info.headRefOid : null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); }
|
||||
|
||||
for (;;) {
|
||||
@@ -409,7 +386,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
|
||||
if (j.phase === 'pushed' && j.base_sha !== baseNow) {
|
||||
if (j.restamps >= MAX_RESTAMPS) {
|
||||
j.restamps = 0; save();
|
||||
if (!driftOf) writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
|
||||
if (!driftOf) lock.release();
|
||||
return fail(lines, { code: 'QUEUE_LAND_BASE_BUSY', message: `origin/${ctx.base} moved during CI ${MAX_RESTAMPS + 1} times; the lock is released and a later run restamps from the pin` }, run);
|
||||
}
|
||||
lines.push(`BASE_MOVED: ${j.base_sha?.slice(0, 12)} -> ${baseNow.slice(0, 12)}; restamping`);
|
||||
@@ -419,7 +396,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
|
||||
const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: j.date ? new Date(`${j.date}T12:00:00Z`) : now() });
|
||||
if ('code' in s) {
|
||||
if (driftOf) setLock('stamping');
|
||||
else writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
|
||||
else lock.release();
|
||||
return fail([...lines], s, run);
|
||||
}
|
||||
lines.push(...s.lines);
|
||||
@@ -471,8 +448,8 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput {
|
||||
j.phase = 'landed'; save();
|
||||
const driftJournal = driftOf ? readJson<LandJournal>(path.join(root, landJournalRel(driftOf))) : null;
|
||||
if (driftOf && driftJournal) writeJsonAtomic(path.join(root, landJournalRel(driftOf)), { ...driftJournal, phase: 'landed' });
|
||||
const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() });
|
||||
return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] };
|
||||
const released = lock.release();
|
||||
return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${released ? 'released' : 'release push failed (the next land takes it over)'}`] };
|
||||
}
|
||||
if (j.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: 'a previous land of this PR found drift', fix: `gstack-ship-queue land --pr ${o.pr} --repair` }, run);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...]
|
||||
train status [--train <nonce>] never spends: rebuild records from refs/notes/gstack-trains, reconcile
|
||||
unconfirmed dispatches by nonce
|
||||
train verdict --train <nonce> read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING
|
||||
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>]
|
||||
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>] [--break-lock [--break-train-lock]]
|
||||
publish slot n's prefix (stamped S_i, else gated T_i): update mode commits its tree on
|
||||
[pin, main], pushes under the pin's lease, waits for checks and merges; direct mode
|
||||
(trains with gates, no strict protection, a green pin) runs gh pr merge
|
||||
@@ -156,7 +156,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
if (!pr || !/^\d+$/.test(pr)) return usage('train land needs --pr <number>');
|
||||
const timeout = positive(flag(rest, '--timeout'), 60);
|
||||
if (Number.isNaN(timeout)) return usage('--timeout needs minutes');
|
||||
return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout }));
|
||||
return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout, breakLock: rest.includes('--break-lock'), breakTrainLock: rest.includes('--break-train-lock') }));
|
||||
}
|
||||
if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') }));
|
||||
if (sub === 'bisect') {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* a second red is real; both attempts' jobs are kept.
|
||||
* - `command`: a clean detached checkout of the prefix head runs on a Ubicloud VM through
|
||||
* scripts/ubicloud/ubi-runner.sh, forwarding only `pass_env`; the VM's `git write-tree` must equal the prefix
|
||||
* tree and exit 0 is GREEN. One spend cap covers every run of the train.
|
||||
* tree and exit 0 is GREEN. One spend cap per command gate covers that gate's runs across every prefix of the train.
|
||||
*
|
||||
* The verdict is per prefix; the train is GREEN when every prefix is, and otherwise the first red prefix after a
|
||||
* green one names the culprit (classifyPrefixes). Slot 1's READY may declare FIXES_BASE_RED: T0 is then allowed
|
||||
@@ -366,7 +366,7 @@ async function settleCommand(env: TrainEnv, t: TrainRecord, a: AttemptRecord, g:
|
||||
lines.push(`COMMAND: gate ${a.gate} on T${p.index} ${r.outcome} (${r.detail})`);
|
||||
}
|
||||
|
||||
/** Run every command unit that has no finished attempt, all prefixes in parallel, under the train's one spend cap. */
|
||||
/** Run every command unit that has no finished attempt, all prefixes in parallel, under one spend cap per command gate across every prefix of the train. */
|
||||
async function runCommands(env: TrainEnv, t: TrainRecord, units: GateUnit[], lines: string[]): Promise<QueueFailure | null> {
|
||||
const commands = units.filter(u => u.workflow === 'command');
|
||||
if (!commands.length) return null;
|
||||
|
||||
@@ -17,16 +17,24 @@
|
||||
* Either way main's tree must be the prefix after the merge; any other main is the freeze path
|
||||
* (QUEUE_TRAIN_TREE_MISMATCH), as for verify. Fork heads are refused. Journaled beside the train record; a rerun
|
||||
* resumes: open at the pin, open at the update commit, or merged (confirmed through main's tree).
|
||||
*
|
||||
* Train land takes the shared land lock (lib/ship-queue-land-lock.ts, #3599) as `train <nonce> slot <i> (#pr)`
|
||||
* before its first main read, so no standalone land publishes beside a slot. After a slot merges with landable
|
||||
* slots left, the lock stays in a train-owned `between` phase that the same train's next slot takes over; a stop
|
||||
* that freezes the queue leaves it in a `freeze` phase only the admitted repair train takes over. The release is
|
||||
* decided once, at the trainLand boundary (settleLock).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { git, writeJsonAtomic } from './ship-queue-gate';
|
||||
import { git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate';
|
||||
import { acquireLandLock, lockLabel, type HeldLandLock, type LandLock, type LandLockPhase } from './ship-queue-land-lock';
|
||||
import { waitChecks } from './ship-queue-land';
|
||||
import { verifyPostMerge } from './ship-queue-post-merge';
|
||||
import { bindTrainReceipt } from './ship-queue-train-gate';
|
||||
import { checkRun, prInfo, type PrInfo } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, nowIso, trainFile, type LandingRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import { TERMINAL_STATES, activeFreeze, attemptsOf, nowIso, trainFile, type LandingRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outPending, outRefused, save, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { confirmMerged, stopped } from './ship-queue-train-verify';
|
||||
|
||||
@@ -36,7 +44,10 @@ const STAMP_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL:
|
||||
const isSha = (s: string) => /^[0-9a-f]{40,64}$/.test(s);
|
||||
interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string }
|
||||
interface Link { index: number; commit: string; tree: string; pin: string }
|
||||
interface Slot { env: TrainEnv; t: TrainRecord; i: number; pin: string; target: Link & { version?: string }; pred: Link; run: string; rerun: string; lines: string[] }
|
||||
interface Slot { env: TrainEnv; t: TrainRecord; i: number; pin: string; target: Link & { version?: string }; pred: Link; run: string; rerun: string; lines: string[]; lock: { held: HeldLandLock | null; effect: boolean } }
|
||||
|
||||
/** Move this slot's land lock to a phase; a failure means another lander took it, so nothing remote may follow. */
|
||||
const lockPhase = (s: Slot, phase: LandLockPhase): QueueFailure | null => s.lock.held?.set(phase) ?? null;
|
||||
|
||||
/** The checks train land requires on the landing commit: changelog_fragments.required_checks, else train.land.required_checks. */
|
||||
export const landRequiredChecks = (env: TrainEnv): string[] => env.policy.policy.changelog_fragments?.required_checks ?? env.train.land?.required_checks ?? [];
|
||||
@@ -131,6 +142,9 @@ async function mergeAt(ctx: QueueContext, s: Slot, head: string, base: string):
|
||||
const target = prInfo(ctx, pr);
|
||||
if ('error' in target) return outPending({ code: 'QUEUE_PROOF_MISSING', message: target.error }, s.lines, s.rerun, s.run);
|
||||
if (target.baseRefName && target.baseRefName !== ctx.base) return retargeted(s, target.baseRefName, ctx.base);
|
||||
const moved = lockPhase(s, 'merging');
|
||||
if (moved) return outFailed(moved, s.lines, s.rerun, s.run);
|
||||
s.lock.effect = true;
|
||||
const m = ctx.gh(['pr', 'merge', pr, `--${method}`, '--match-head-commit', head]);
|
||||
if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, s.lines, s.rerun, s.run);
|
||||
main = mainNow(ctx, s.env);
|
||||
@@ -189,6 +203,8 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
|
||||
writeJsonAtomic(file, j);
|
||||
recordLanding(s, 'update', commit, base);
|
||||
const pushed = info.headRefOid !== commit;
|
||||
const pushing = lockPhase(s, 'pushing');
|
||||
if (pushing) return outFailed(pushing, s.lines, s.rerun, s.run);
|
||||
if (pushed) {
|
||||
const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${info.headRefOid}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]);
|
||||
if (!push.ok) {
|
||||
@@ -198,6 +214,9 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
|
||||
if (!remote.ok || now !== commit) return outFailed({ code: 'QUEUE_NOT_READY', message: `the ${what} push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, s.lines, s.rerun, s.run);
|
||||
}
|
||||
}
|
||||
s.lock.effect = true;
|
||||
const waiting = lockPhase(s, 'waiting');
|
||||
if (waiting) return outFailed(waiting, s.lines, s.rerun, s.run);
|
||||
s.lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree ${label(s)}, parents [pin, main ${base.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`);
|
||||
if (o.noWait && pushed) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the ${what} commit ${commit.slice(0, 12)}` }, s.lines, `gstack-pr-wait ${pr} --until green && ${s.rerun}`, s.run);
|
||||
if (!o.noWait) waitChecks(ctx, commit, o.timeoutMin ?? 60);
|
||||
@@ -220,7 +239,42 @@ async function landUpdate(ctx: QueueContext, s: Slot, info: PrInfo, base: string
|
||||
|
||||
const retargeted = (s: Slot, b: string, base: string) => outRefused({ code: 'QUEUE_NOT_READY', message: `#${s.t.slots[s.i]!.pr} targets ${b}, not ${base}; train land merges only PRs on the base the train built` }, s.lines, `retarget #${s.t.slots[s.i]!.pr} to ${base}, then ${s.rerun}`, s.run);
|
||||
|
||||
export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise<TrainOut> {
|
||||
interface TrainLandOptions { train: string; pr: string; noWait?: boolean; timeoutMin?: number; breakLock?: boolean; breakTrainLock?: boolean }
|
||||
|
||||
/**
|
||||
* The takeovers train land allows: its own train's between-slots lock, the freeze lock when the freeze admits this
|
||||
* train as its repair (or the freeze is cleared), and a lock whose train record is terminal.
|
||||
*/
|
||||
function trainTakeover(env: TrainEnv, t: TrainRecord, i: number, lock: LandLock): string | null {
|
||||
if (lock.train === t.nonce && lock.phase === 'between') return `LOCK: train ${t.nonce} slot ${i + 1} takes over its train's between-slots lock (from slot ${lock.slot})`;
|
||||
if (lock.phase === 'freeze') {
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze?.repair?.train === t.nonce) return `LOCK: repair train ${t.nonce} takes over the freeze lock of ${lockLabel(lock)}`;
|
||||
return freeze ? null : `LOCK: train ${t.nonce} takes over ${lockLabel(lock)}'s freeze lock; that freeze is cleared`;
|
||||
}
|
||||
if (!lock.train || lock.train === t.nonce) return null;
|
||||
const other = findTrain(env, lock.train);
|
||||
return other && TERMINAL_STATES.includes(other.state) ? `LOCK: train ${t.nonce} takes over ${lockLabel(lock)}'s lock; that train is ${other.state}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one release point: a freeze keeps the lock in `freeze`; a merged slot with landable slots left, or a pending
|
||||
* wait between slots, keeps it `between`; a failure after a remote effect keeps its phase for the resume; anything
|
||||
* else releases it.
|
||||
*/
|
||||
function settleLock(s: Slot, out: TrainOut): TrainOut {
|
||||
const held = s.lock.held!;
|
||||
const midway = s.t.state === 'merging' && s.t.merged.length > 0;
|
||||
const keep = (phase: LandLockPhase, line: string) => (held.set(phase) ? `LOCK: ${phase} could not be written (another lander took the lock)` : line);
|
||||
let line: string;
|
||||
if (activeFreeze(s.env.repo.keys)) line = keep('freeze', 'LOCK: freeze (kept for the repair train the freeze admits; --break-lock never breaks it)');
|
||||
else if (midway && (out.exit === 0 || (out.status === 'incomplete' && !s.lock.effect))) line = keep('between', `LOCK: between (train ${s.t.nonce} keeps the land lock for its next slot)`);
|
||||
else if (out.exit !== 0 && s.lock.effect) line = `LOCK: kept in phase ${held.lock.phase}; ${s.rerun} resumes it`;
|
||||
else line = held.release() ? 'LOCK: released' : 'LOCK: release push failed (the next lander takes it over)';
|
||||
return { ...out, lines: [...out.lines, line] };
|
||||
}
|
||||
|
||||
export async function trainLand(ctx: QueueContext, o: TrainLandOptions): Promise<TrainOut> {
|
||||
const fetchErr = fetchBase(ctx);
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
@@ -241,8 +295,24 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin
|
||||
if (!chain) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's T0 and ${t.stamped ? 'S' : 'T'}1..${t.stamped ? 'S' : 'T'}${k} (with each slot's pin as second parent) are not the first-parent chain of the gated head ${t.head.slice(0, 12)}` }, [], `${Q} train status --train ${t.nonce}`, run);
|
||||
const pin = t.slots[i]!.head;
|
||||
const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`;
|
||||
const s: Slot = { env, t, i, pin, target: { ...chain[i + 1]!, version: t.stamped?.[i]?.version }, pred: chain[i]!, run, rerun, lines: [] };
|
||||
const s: Slot = { env, t, i, pin, target: { ...chain[i + 1]!, version: t.stamped?.[i]?.version }, pred: chain[i]!, run, rerun, lines: [], lock: { held: null, effect: false } };
|
||||
s.lines.push(`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${k} #${o.pr} pin ${pin} -> ${label(s)}${s.target.version ? ` v${s.target.version}` : ''} tree ${s.target.tree}`);
|
||||
const got = acquireLandLock({
|
||||
root: ctx.repoRoot, remote: env.remote, holder: `${os.hostname()}:${process.pid}`, owner: { train: t.nonce, slot: i + 1, pr: o.pr }, journal: `train/${t.nonce}/slot-${i + 1}`,
|
||||
now: () => new Date(), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock,
|
||||
phase: own => (own && ['pushing', 'waiting', 'merging'].includes(own.phase) ? own.phase : 'stamping'),
|
||||
takeover: lock => trainTakeover(env, t, i, lock),
|
||||
liveTrain: nonce => { const other = findTrain(env, nonce); return !other || !TERMINAL_STATES.includes(other.state); },
|
||||
});
|
||||
s.lines.push(...got.lines);
|
||||
if ('failure' in got) return outRefused(got.failure, s.lines, `wait for the holder named above, then ${rerun}`, run);
|
||||
s.lock = { held: got.held, effect: !!got.held.resumed && ['pushing', 'waiting', 'merging'].includes(got.held.resumed.phase) };
|
||||
return settleLock(s, await landSlot(ctx, s, chain, o));
|
||||
}
|
||||
|
||||
/** Train land under the lock: main read, confirmation, post-merge checks of the previous slot, then direct or update. */
|
||||
async function landSlot(ctx: QueueContext, s: Slot, chain: Link[], o: TrainLandOptions): Promise<TrainOut> {
|
||||
const { env, t, i, pin, run, rerun } = s;
|
||||
const main = mainNow(ctx, env);
|
||||
if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, s.lines, rerun, run);
|
||||
if (main.tree === s.target.tree) return confirmed(s, main.sha, `main: ${main.sha} is ${label(s)} exactly`);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* head, or at a head whose tree is the slot's prefix after it modulo stamps
|
||||
* (from slot 2 on, the lane merges main into its pin so GitHub can merge it),
|
||||
* with main's tree equal to the prefix before the PR (pre-merge:
|
||||
* MERGE_CONDITION) or after it (post-merge confirmation), modulo stamps
|
||||
* (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
|
||||
* MERGE_CONDITION, once the head carries its own stamp above main's version,
|
||||
* #3531) or after it (post-merge confirmation, once GitHub reports the PR
|
||||
* MERGED), modulo stamps (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
|
||||
* head after earlier slots merged) the queue freezes: the freeze is written
|
||||
* locally and to the notes ref first, then the backstop gate is dispatched on
|
||||
* main's published prefix at once. One repair train is admitted under the
|
||||
@@ -25,6 +26,9 @@ import {
|
||||
} from './ship-queue-train';
|
||||
import { mergeChangelogs, ownEntryAboveMain, parseChangelog } from './changelog-check';
|
||||
import { buildReceipt } from './tree-receipt';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { cmpVersion, parseVersion } from './version-source';
|
||||
import { readLock, writeLock } from './ship-queue-land-lock';
|
||||
import { genericBackstop } from './ship-queue-train-gate';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
@@ -110,6 +114,31 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa
|
||||
return { same: true, detail: `same modulo stamps (${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)) except ${cl}, main ${parent.slice(0, 12)}'s ${cl} plus #${t.slots[i]!.pr}'s own entry` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot stamp a hand-stamped head must carry before it may merge (#3531, train 33 shipped #3527 under #3526's
|
||||
* version): its VERSION above main's and, with a CHANGELOG, its top section headed with that version directly above
|
||||
* main's top release, with no [Unreleased] section when main has none. The failing rule is named.
|
||||
*/
|
||||
function stampProof(env: TrainEnv, head: string, main: string): { version: string } | { rule: string; detail: string } {
|
||||
const root = env.ctx.repoRoot;
|
||||
const hv = versionAtRef(root, head);
|
||||
const mv = versionAtRef(root, main);
|
||||
if ('error' in hv) return { rule: 'version', detail: `the head's version cannot be read (${hv.error})` };
|
||||
if ('error' in mv) return { rule: 'version', detail: `main's version cannot be read (${mv.error})` };
|
||||
const [h, m] = [parseVersion(hv.version), parseVersion(mv.version)];
|
||||
if (!h || !m || cmpVersion(h, m) <= 0) return { rule: 'version', detail: `the head's ${hv.rel} ${hv.version} is not above main's ${mv.version}` };
|
||||
const cl = env.policy.policy.changelog;
|
||||
if (!cl) return { version: hv.version };
|
||||
const sections = (rev: string) => parseChangelog(git(root, ['show', `${rev}:${cl}`]).out).sections;
|
||||
const [ours, theirs] = [sections(head), sections(main)];
|
||||
const mainTop = theirs.find(s => !s.unreleased)?.version ?? null;
|
||||
if (ours[0]?.version !== hv.version) return { rule: 'changelog', detail: `${cl}'s top section at the head is [${ours[0] ? ours[0].version ?? 'Unreleased' : 'none'}], not [${hv.version}]` };
|
||||
const below = ours.slice(1).find(s => !s.unreleased)?.version ?? null;
|
||||
if (below !== mainTop) return { rule: 'changelog', detail: `${cl}'s release below [${hv.version}] at the head is [${below ?? 'none'}], not main's top release [${mainTop ?? 'none'}]` };
|
||||
if (!theirs.some(s => s.unreleased) && ours.some(s => s.unreleased)) return { rule: 'changelog', detail: `${cl} at the head keeps an [Unreleased] section that main does not have` };
|
||||
return { version: hv.version };
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the queue: the record goes to local state and the notes ref before
|
||||
* anything remote, then the backstop gate is dispatched on main's sha.
|
||||
@@ -225,8 +254,13 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const before = t.prefixes[i]!;
|
||||
const post = published(env, t, i, main);
|
||||
const lines = [`TRAIN: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pinned ${t.slots[i]!.head}`];
|
||||
if (post.same) return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]);
|
||||
const pre = sameModuloStamps(env, before.commit, main);
|
||||
if (post.same) {
|
||||
const state = prState(ctx, o.pr);
|
||||
if (state === 'MERGED') return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]);
|
||||
if (state !== 'OPEN' || !pre.same) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `main ${main.slice(0, 12)} is T${after.index} modulo stamps (${post.detail}), but GitHub reports #${o.pr} as ${state}, not MERGED; nothing is confirmed and the queue is not frozen` }, lines, `gh pr view ${o.pr} --json state,mergeCommit; rerun once GitHub reports #${o.pr} MERGED: ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
lines.push(`main: also T${after.index} modulo stamps, but #${o.pr} is OPEN, so this is the pre-merge check (a stamp-only PR)`);
|
||||
}
|
||||
if (!pre.same) {
|
||||
return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.slice(0, 12)} is neither T${before.index} nor T${after.index} modulo stamps (vs T${after.index}: ${post.detail})`, main, lines, !o.noFetch);
|
||||
}
|
||||
@@ -239,7 +273,9 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
const shipped = bindReceipt(info.body, o.pr, [t.slots[i]!.head, head]);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [...lines, shipped.line], `#${o.pr}'s lane runs /ship and READYs again; then ${Q} train build --prs <pins>`, run);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`,
|
||||
const stamp = stampProof(env, head, main);
|
||||
if ('rule' in stamp) return outRefused({ code: 'QUEUE_TRAIN_UNSTAMPED', message: `#${o.pr}'s head ${head.slice(0, 12)} is not stamped above main: ${stamp.rule}: ${stamp.detail}` }, lines, `#${o.pr}'s lane runs gstack-restamp --next on its head, commits what restamp staged, pushes, then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `${shipped.line} (binds ${shipped.head === t.slots[i]!.head ? 'the pinned head' : 'the current head'})`, `STAMP_VERSION: ${stamp.version}`,
|
||||
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
|
||||
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
}
|
||||
@@ -262,6 +298,8 @@ export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: s
|
||||
freeze.updated_at = freeze.cleared_at;
|
||||
persist(env.ctx.repoRoot, [freeze], { remote: env.remote });
|
||||
lines.push(`FREEZE: cleared (${freeze.cleared_by})`);
|
||||
const held = readLock(env.ctx.repoRoot, env.remote);
|
||||
if (held.state === 'held' && held.lock.phase === 'freeze' && writeLock(env.ctx.repoRoot, held.sha, { released: true, by: `verify #${pr} via train ${t.nonce}`, at: nowIso() }, env.remote).ok) lines.push('LOCK: released the freeze lock');
|
||||
const rebuild = freeze.unmerged.length ? `${Q} train build --prs ${pinList(freeze.unmerged)}` : '';
|
||||
const unread = freeze.unreadable ?? [];
|
||||
const check = unread.length ? `${unread.map(u => `gh pr view ${u.pr} --json state,headRefOid`).join(' && ')} (unreadable at the freeze: ${unread.map(u => `#${u.pr}@${u.head.slice(0, 12)}`).join(', ')}); seat each that is OPEN in a train` : '';
|
||||
|
||||
@@ -295,6 +295,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-queue-train-land.test.ts',
|
||||
reason: 'drives train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-queue-land-lock.test.ts',
|
||||
reason: 'drives land and train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train-land.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-queue-train.test.ts',
|
||||
reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)',
|
||||
|
||||
+1
@@ -171,6 +171,7 @@
|
||||
{ "file": "lib/ship-queue-train-verify.ts", "movedFrom": "(new: verify --train, the queue freeze and its backstop, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-train-ready.ts", "movedFrom": "(new: ready --screen|--no-screen and the READY receipt, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/ship-queue-land-lock.ts", "movedFrom": "lib/ship-queue-land.ts (the land lock, shared with train land, #3599)" },
|
||||
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
|
||||
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* version-source shapes (package-manifest pin, monorepo pin, absent source).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
@@ -163,7 +164,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
expect(changed.status).toBe(EXIT.fail);
|
||||
expect(changed.out).toMatch(/^TREE: changed \(pr-b\.txt\)$/m);
|
||||
expect(changed.out).toMatch(/^gate-reuse: not-eligible \(tree changed: pr-b\.txt\) — fix: .* \(GATE_REUSE_NOT_ELIGIBLE\)$/m);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('--dry-run prints the full diff and writes nothing; --was overrides the mention source', () => {
|
||||
const repo = fixture();
|
||||
@@ -191,7 +192,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
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);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=a bump never repo's --next never invents a MICRO stamp: without --bump or --version it refuses and writes nothing, and an explicit level stamps as before; fails_when=restamp falls back to micro for a repo whose policy names no level;
|
||||
// why_new=GST-380 added bump never; seam=gstack-ship-policy, gstack-version-bump write; no_claim=the claim queue read
|
||||
@@ -454,7 +455,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
|
||||
const viaGh = runBin(repo, 'gstack-tree-receipt', ['--from', path.join(repo.work, '.gstack/tmp/gate-ahead.json'), '--predecessor-pr', '1', '--bundle', bundle, '--base', 'main', '--json'], ghShim(repo, { '1': { headRefOid: repo.git('rev-parse', 'pr-a') } }));
|
||||
expect(JSON.parse(viaGh.out).predecessor.moved).toBe(true);
|
||||
expect(JSON.parse(viaGh.out).code).toBe('RESTAMP_PREDECESSOR_MOVED');
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test('a gate-ahead merge that conflicts outside the stamp files is RESTAMP_MERGE_CONFLICT with nothing written', () => {
|
||||
const repo = fixture();
|
||||
@@ -587,3 +588,75 @@ describe('version-source shapes', () => {
|
||||
expect(fs.existsSync(path.join(repo.work, 'VERSION'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-restamp stages what it writes (#3531)', () => {
|
||||
/** Branch b forks from main; branch a stamps and merges first, so b's base merge conflicts only on the CHANGELOG. */
|
||||
function behindMain(): FixtureRepo {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-b');
|
||||
makePr(repo, 'pr-a');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', 'stamp pr-a');
|
||||
mergeToMain(repo, 'pr-a');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', 'pr-b');
|
||||
return repo;
|
||||
}
|
||||
const cached = (repo: FixtureRepo) => repo.git('diff', '--cached', '--name-only').split('\n').filter(Boolean);
|
||||
const unstaged = (repo: FixtureRepo) => repo.git('diff', '--name-only').split('\n').filter(Boolean);
|
||||
const filesOf = (out: string) => (/^FILES: (.*)$/m.exec(out)?.[1] ?? '').split(' ').filter(Boolean);
|
||||
|
||||
test('after --next on a staged no-commit base merge, every FILES entry is staged, STAGED: names them, and a bare commit carries the stamp', () => {
|
||||
const repo = behindMain();
|
||||
const merge = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
|
||||
if (merge.status !== 0) {
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n')!);
|
||||
repo.git('add', 'CHANGELOG.md');
|
||||
}
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
const files = filesOf(r.out);
|
||||
expect(files).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
|
||||
expect(cached(repo)).toEqual(expect.arrayContaining(files));
|
||||
expect(unstaged(repo).filter(f => files.includes(f))).toEqual([]);
|
||||
const staged = /^STAGED: (.*)$/m.exec(r.out)?.[1]?.split(' ') ?? [];
|
||||
expect(staged).toEqual(expect.arrayContaining(files));
|
||||
repo.git('commit', '-q', '--no-edit');
|
||||
expect(repo.git('show', 'HEAD:VERSION')).toBe('1.2.5.0');
|
||||
expect(repo.git('show', 'HEAD:CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.2\.5\.0\]/);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a stamp file with unstaged edits of its own refuses before writing (RESTAMP_UNSTAGED_EDITS); nothing written or staged', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-c');
|
||||
fs.appendFileSync(path.join(repo.work, 'CHANGELOG.md'), '\n- an edit the caller has not staged\n');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/CHANGELOG\.md holds? unstaged edits of your own.*nothing written.*\(RESTAMP_UNSTAGED_EDITS\)/);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
|
||||
expect(cached(repo)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a staging failure prints RESTAMP_NOT_STAGED with the files written and a marker; the noop rerun stages them', () => {
|
||||
const repo = fixture();
|
||||
makePr(repo, 'pr-d');
|
||||
const lock = path.join(repo.work, '.git', 'index.lock');
|
||||
fs.writeFileSync(lock, '');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain('(RESTAMP_NOT_STAGED)');
|
||||
expect(r.out).not.toMatch(/^STAGED:/m);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
expect(cached(repo)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(true);
|
||||
fs.rmSync(lock, { force: true });
|
||||
const again = restamp(repo, ['--next']);
|
||||
expect(again.status, again.err).toBe(EXIT.ok);
|
||||
expect(again.out).toMatch(/^RESTAMP: .*\(noop;/m);
|
||||
expect(cached(repo)).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
|
||||
expect(again.out).toMatch(/^STAGED: .*VERSION/m);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,41 @@ export function trainJson(fx: TrainFixture, nonce: string, state = fx.state): an
|
||||
}
|
||||
export const dispatches = (fx: TrainFixture, workflow?: string) => fx.read().log.filter(l => l.cmd === 'workflow run' && (!workflow || l.workflow === workflow));
|
||||
|
||||
/** For train land and land: pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
|
||||
export function wrapLand(fx: TrainFixture): void {
|
||||
const dir = path.join(fx.repo.root, 'gh-land-wrap');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
|
||||
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');let tree=rev(want+'^{tree}');",
|
||||
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
fx.env.PATH = `${dir}:${fx.env.PATH}`;
|
||||
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
|
||||
}
|
||||
|
||||
/** A changelog_fragments PR: a fragment under changelog.d/, pushed as pr-<n>, with a READY receipt. */
|
||||
export function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
|
||||
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
|
||||
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
|
||||
readyReceipt(fx, pr, head);
|
||||
return head;
|
||||
}
|
||||
function shimSource(): string {
|
||||
return [
|
||||
"const fs = require('fs'); const path = require('path'); const cp = require('child_process');",
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* The shared land lock (lib/ship-queue-land-lock.ts, #3599): `land` and `train land` take the same
|
||||
* refs/notes/gstack-land lock, so a standalone land and a train slot can never both publish onto one main.
|
||||
* Module cases run against a bare origin and two clones (the race, the read states, owner kinds, the
|
||||
* --break-lock rules); command cases drive bin/gstack-ship-queue against the train fixture and its gh
|
||||
* shims (both directions, the train-scoped between phase, refusal and resume, the freeze-owned lock and the
|
||||
* repair train's takeover).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { acquireLandLock, LAND_LOCK_REF, readLock, writeLock, type LandLock, type LockOwner } from '../lib/ship-queue-land-lock';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, fragmentPr, nonceOf, queue, trainFixture, trainJson, wrapLand, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => {
|
||||
while (repos.length) cleanup(repos.pop()!);
|
||||
while (fixtures.length) cleanup(fixtures.pop()!.repo);
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-31T12:00:00Z');
|
||||
const at = (min: number) => () => new Date(T0 + min * 60_000);
|
||||
|
||||
/** Run fn with the fixture's git identity in process.env (the lock writes commits in-process). */
|
||||
function withRepoEnv<T>(repo: FixtureRepo, fn: () => T): T {
|
||||
const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]]));
|
||||
Object.assign(process.env, repo.env);
|
||||
try { return fn(); } finally {
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
}
|
||||
}
|
||||
function bareRepo(): FixtureRepo { const r = makeFixtureRepo({ policy: BASE_POLICY, prefix: 'gstack-land-lock-' }); repos.push(r); return r; }
|
||||
const standalone = (pr: string): LockOwner => ({ pr });
|
||||
const slotOf = (train: string, slot: number, pr: string): LockOwner => ({ train, slot, pr });
|
||||
function take(root: string, owner: LockOwner, o: { journal?: string; now?: () => Date; breakLock?: boolean; breakTrainLock?: boolean; liveTrain?: (nonce: string) => boolean; phase?: LandLock['phase'] } = {}) {
|
||||
return acquireLandLock({
|
||||
root, holder: 'test:1', owner, journal: o.journal ?? ('train' in owner && owner.train ? `train/${owner.train}/slot-${owner.slot}` : `land/${owner.pr}`),
|
||||
phase: () => o.phase ?? 'stamping', now: o.now ?? at(0), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock, liveTrain: o.liveTrain,
|
||||
});
|
||||
}
|
||||
const failureOf = (r: ReturnType<typeof take>) => ('failure' in r ? r.failure : null);
|
||||
|
||||
describe('the lock module', () => {
|
||||
test('two writers racing the same lock sha leave one winner', () => {
|
||||
const repo = bareRepo();
|
||||
const other = coordinatorClone(repo);
|
||||
withRepoEnv(repo, () => {
|
||||
const lock = (pr: string): LandLock => ({ pr, holder: `h${pr}`, started_at: new Date(T0).toISOString(), phase: 'stamping', journal: `land/${pr}` });
|
||||
expect(readLock(repo.work).state).toBe('absent');
|
||||
const first = writeLock(repo.work, '', lock('1'));
|
||||
const second = writeLock(other, '', lock('2'));
|
||||
expect([first.ok, second.ok]).toEqual([true, false]);
|
||||
const seen = readLock(other);
|
||||
expect(seen.state === 'held' && seen.lock.pr).toBe('1');
|
||||
const a = writeLock(repo.work, seen.sha, lock('3'));
|
||||
const b = writeLock(other, seen.sha, lock('4'));
|
||||
expect([a.ok, b.ok]).toEqual([true, false]);
|
||||
const final = readLock(other);
|
||||
expect(final.state === 'held' && final.lock.pr).toBe('3');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('absent, released, held and unreadable are distinct; an unreadable lock refuses, and --break-lock replaces it', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
expect(readLock(root)).toMatchObject({ state: 'absent' });
|
||||
const got = take(root, standalone('5'));
|
||||
expect(failureOf(got)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '5', phase: 'stamping' } });
|
||||
if ('held' in got) expect(got.held.release()).toBe(true);
|
||||
expect(readLock(root)).toMatchObject({ state: 'released' });
|
||||
const junk = repo.git('commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'not a lock');
|
||||
repo.git('push', '-q', '-f', 'origin', `${junk}:${LAND_LOCK_REF}`);
|
||||
expect(readLock(root)).toMatchObject({ state: 'unreadable' });
|
||||
const refused = take(root, standalone('6'));
|
||||
expect(failureOf(refused)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(refused)!.message).toContain('cannot be read');
|
||||
const broke = take(root, standalone('6'), { breakLock: true });
|
||||
expect(failureOf(broke)).toBeNull();
|
||||
expect(broke.lines.join('\n')).toContain(`LOCK: broke an unreadable lock at ${junk.slice(0, 12)}`);
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '6' } });
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('ownership compares the owner kind and journal: the same PR across commands and across trains refuses; a resume of the same journal and a sequential acquisition succeed', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
const mine = take(root, standalone('7'), { phase: 'waiting' });
|
||||
expect(failureOf(mine)).toBeNull();
|
||||
const asSlot = take(root, slotOf('trainA', 1, '7'));
|
||||
expect(failureOf(asSlot)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(asSlot)!.message).toMatch(/^#7 holds the land lock \(test:1, phase waiting, since /);
|
||||
const resumed = take(root, standalone('7'), { now: at(3) });
|
||||
expect(failureOf(resumed)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '7', started_at: new Date(T0).toISOString() } });
|
||||
if ('held' in resumed) resumed.held.release();
|
||||
|
||||
const trainA = take(root, slotOf('trainA', 1, '7'), { phase: 'pushing' });
|
||||
expect(failureOf(trainA)).toBeNull();
|
||||
expect(readLock(root)).toMatchObject({ state: 'held', lock: { train: 'trainA', slot: 1, pr: '7' } });
|
||||
const trainB = take(root, slotOf('trainB', 1, '7'));
|
||||
expect(failureOf(trainB)!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
expect(failureOf(take(root, standalone('7')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
expect(failureOf(take(root, slotOf('trainA', 2, '8')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
|
||||
const same = take(root, slotOf('trainA', 1, '7'));
|
||||
expect(failureOf(same)).toBeNull();
|
||||
if ('held' in same) same.held.release();
|
||||
expect(failureOf(take(root, standalone('8')))).toBeNull();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('--break-lock prints the holder, refuses a live train run\'s lock without --break-train-lock, and keeps the staleness, mid-push, between and freeze rules', () => {
|
||||
const repo = bareRepo();
|
||||
withRepoEnv(repo, () => {
|
||||
const root = repo.work;
|
||||
const hold = (owner: LockOwner, phase: LandLock['phase']) => {
|
||||
const r = readLock(root);
|
||||
if (r.state === 'held') writeLock(root, r.sha, { released: true, by: 'test', at: new Date(T0).toISOString() });
|
||||
expect(failureOf(take(root, owner, { phase }))).toBeNull();
|
||||
};
|
||||
hold(slotOf('trainA', 2, '9'), 'waiting');
|
||||
const live = take(root, standalone('3'), { breakLock: true, now: at(1) });
|
||||
expect(failureOf(live)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
|
||||
expect(failureOf(live)!.message).toContain('--break-train-lock');
|
||||
expect(live.lines).toContain(`LOCK_HOLDER: train trainA slot 2 (#9) holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
|
||||
const ended = take(root, standalone('3'), { breakLock: true, now: at(1), liveTrain: () => false });
|
||||
expect(failureOf(ended)).toBeNull();
|
||||
expect(ended.lines).toContain("LOCK: broke train trainA slot 2 (#9)'s lock (test:1, phase waiting)");
|
||||
|
||||
hold(slotOf('trainA', 2, '9'), 'waiting');
|
||||
const forced = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
|
||||
expect(failureOf(forced)).toBeNull();
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'between');
|
||||
const young = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
|
||||
expect(failureOf(young)!.message).toMatch(/train trainA slot 1 \(#9\) is between slots \(60 s ago\); --break-lock refused/);
|
||||
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(11) }))).toBeNull();
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'pushing');
|
||||
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }))!.message).toMatch(/train trainA slot 1 \(#9\)'s holder is mid-push \(60 s ago\); --break-lock refused/);
|
||||
|
||||
hold(slotOf('trainA', 1, '9'), 'freeze');
|
||||
const frozen = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(60) });
|
||||
expect(failureOf(frozen)).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
|
||||
expect(failureOf(take(root, slotOf('trainB', 1, '4'), { breakLock: true, breakTrainLock: true, now: at(60) }))).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
|
||||
|
||||
hold(standalone('5'), 'waiting');
|
||||
const plain = take(root, standalone('3'), { breakLock: true, now: at(1) });
|
||||
expect(plain.lines).toContain(`LOCK_HOLDER: #5 holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
|
||||
expect(plain.lines).toContain("LOCK: broke #5's lock (test:1, phase waiting)");
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
function stampedTrain(prs = ['1', '2']) {
|
||||
const fx = trainFixture(POLICY, 'gstack-land-lock-'); fixtures.push(fx); wrapLand(fx);
|
||||
const heads = prs.map(pr => fragmentPr(fx, pr));
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', prs.map((pr, i) => `${pr}@${heads[i]}`).join(',')]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
return { fx, heads, nonce };
|
||||
}
|
||||
const trainLand = (fx: TrainFixture, nonce: string, pr: string, extra: string[] = []) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait', ...extra]);
|
||||
const land = (fx: TrainFixture, pr: string, extra: string[] = []) => queue(fx, ['land', '--pr', pr, '--no-wait', '--allow-repo-commands', ...extra]);
|
||||
const originRev = (fx: TrainFixture, ref: string) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', '--verify', '-q', ref);
|
||||
/** The lock as origin holds it: the parsed lock.json, or null when origin has no lock ref. */
|
||||
function originLock(fx: TrainFixture): any {
|
||||
const sha = (() => { try { return originRev(fx, LAND_LOCK_REF); } catch { return ''; } })();
|
||||
return sha ? JSON.parse(fx.repo.git('--git-dir', fx.repo.origin, 'show', `${sha}:lock.json`)) : null;
|
||||
}
|
||||
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze');
|
||||
|
||||
describe('land and train land share the lock', () => {
|
||||
test('a train slot holding the lock refuses a standalone land (no takeover, no freeze; --break-lock names the train and wants --break-train-lock); the train keeps it between slots and releases it after the last', () => {
|
||||
const { fx, heads, nonce } = stampedTrain();
|
||||
const pin3 = fragmentPr(fx, '3');
|
||||
const pushed = trainLand(fx, nonce, '1');
|
||||
expect(pushed.out, pushed.err).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
|
||||
const lockSha = originRev(fx, LAND_LOCK_REF);
|
||||
|
||||
const blocked = land(fx, '3');
|
||||
expect(blocked.status).toBe(EXIT.refused);
|
||||
expect(blocked.err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase waiting.*\\(QUEUE_LAND_LOCKED\\)`));
|
||||
const broke = land(fx, '3', ['--break-lock']);
|
||||
expect(broke.status).toBe(EXIT.refused);
|
||||
expect(broke.err).toContain('--break-train-lock');
|
||||
expect(broke.out).toMatch(new RegExp(`^LOCK_HOLDER: train ${nonce} slot 1 \\(#1\\) holder=\\S+ phase=waiting since=`, 'm'));
|
||||
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
|
||||
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
|
||||
const merged = trainLand(fx, nonce, '1');
|
||||
expect(merged.status, merged.err).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'between' });
|
||||
expect(land(fx, '3').err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase between.*\\(QUEUE_LAND_LOCKED\\)`));
|
||||
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
|
||||
|
||||
const second = trainLand(fx, nonce, '2');
|
||||
expect(second.out, second.err).toMatch(/^LOCK: train \S+ slot 2 takes over its train's between-slots lock/m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 2, pr: '2', phase: 'waiting' });
|
||||
expect(trainLand(fx, nonce, '2').status).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
expect(trainJson(fx, nonce).state).toBe('merged');
|
||||
expect(land(fx, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
|
||||
void heads;
|
||||
}, 300_000);
|
||||
|
||||
test('a standalone land holding the lock refuses train land; a refusal before any push releases the train\'s lock', () => {
|
||||
const { fx, heads, nonce } = stampedTrain();
|
||||
fragmentPr(fx, '3');
|
||||
expect(land(fx, '3').out).toMatch(/^PUSHED: /m);
|
||||
const blocked = trainLand(fx, nonce, '1');
|
||||
expect(blocked.status).toBe(EXIT.refused);
|
||||
expect(blocked.err).toMatch(/#3 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
|
||||
expect(blocked.out).not.toMatch(/^PUSHED:/m);
|
||||
expect(originRev(fx, 'refs/heads/pr-1')).toBe(heads[0]);
|
||||
|
||||
expect(land(fx, '3').out).toContain('LOCK: released');
|
||||
const stale = trainLand(fx, nonce, '1');
|
||||
expect(stale.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 300_000);
|
||||
|
||||
test('after a push the train keeps its lock through a failed wait and a resume takes it back', () => {
|
||||
const { fx, nonce } = stampedTrain(['1']);
|
||||
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(trainLand(fx, nonce, '1').out)![1]!;
|
||||
fx.write(d => { d.ci[stamp] = [{ name: 'free-tests', status: 'completed', conclusion: 'failure' }] as any; });
|
||||
const red = trainLand(fx, nonce, '1');
|
||||
expect(red.status).not.toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
|
||||
fx.write(d => { delete d.ci[stamp]; });
|
||||
const resumed = trainLand(fx, nonce, '1');
|
||||
expect(resumed.status, resumed.err).toBe(EXIT.ok);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 300_000);
|
||||
|
||||
test('a stop that freezes leaves the lock in a freeze phase that --break-lock never breaks; only the admitted repair train takes it over, and it is released once the freeze clears', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
trainLand(fx, nonce, '1');
|
||||
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
|
||||
trainLand(fx, nonce, '2');
|
||||
fx.write(d => { (d as any).outsideOnMerge = true; });
|
||||
const drift = trainLand(fx, nonce, '2');
|
||||
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
|
||||
expect(drift.out).toMatch(/^QUEUE_TRAIN_FROZEN: /m);
|
||||
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'freeze' });
|
||||
const lockSha = originRev(fx, LAND_LOCK_REF);
|
||||
|
||||
fragmentPr(fx, '3');
|
||||
const refused = land(fx, '3', ['--break-lock', '--break-train-lock']);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
|
||||
|
||||
const h4 = fragmentPr(fx, '4');
|
||||
const repair = queue(fx, ['train', 'build', '--repair', '--prs', `4@${h4}`]);
|
||||
expect(repair.status, repair.err).toBe(EXIT.ok);
|
||||
const rn = nonceOf(repair.out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
const taken = trainLand(fx, rn, '4');
|
||||
expect(taken.out, taken.err).toMatch(new RegExp(`^LOCK: repair train ${rn} takes over the freeze lock of train ${nonce}`, 'm'));
|
||||
expect(originLock(fx)).toMatchObject({ train: rn, slot: 1, pr: '4', phase: 'freeze' });
|
||||
expect(land(fx, '3', ['--break-lock', '--break-train-lock']).err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
const cleared = trainLand(fx, rn, '4');
|
||||
expect(cleared.status, cleared.err).toBe(EXIT.ok);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
|
||||
expect(originLock(fx)).toMatchObject({ released: true });
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -10,47 +10,13 @@ import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup } from './helpers/restamp-fixture';
|
||||
import {
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
|
||||
function wrap(fx: TrainFixture): void {
|
||||
const dir = path.join(fx.repo.root, 'gh-land-wrap');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
|
||||
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');let tree=rev(want+'^{tree}');",
|
||||
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
fx.env.PATH = `${dir}:${fx.env.PATH}`;
|
||||
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
|
||||
}
|
||||
|
||||
function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
|
||||
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
|
||||
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
|
||||
readyReceipt(fx, pr, head);
|
||||
return head;
|
||||
}
|
||||
function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) {
|
||||
const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
||||
const h1 = fragmentPr(fx, '1');
|
||||
@@ -193,7 +159,7 @@ describe('stamped trains: build and land', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 });
|
||||
const failed = land(fx, nonce, '1');
|
||||
expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/);
|
||||
expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED');
|
||||
|
||||
+185
-13
@@ -59,6 +59,34 @@ function publishMain(fx: TrainFixture, prefixCommit: string, version: string, ex
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
/** The lane's slot stamp on its pin (main merged in first from slot 2 on): gstack-restamp --next, then the stamp files committed and pushed as the PR head. */
|
||||
function laneStamp(fx: TrainFixture, pr: string, pin: string, o: { mergeMain?: boolean; dropStamp?: boolean } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('reset', '-q', '--hard');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', '-B', `lane${pr}`, pin);
|
||||
if (o.mergeMain) {
|
||||
const m = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
|
||||
if (m.status !== 0) {
|
||||
const side = (n: number) => repo.git('show', `:${n}:CHANGELOG.md`) + '\n';
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(side(2), side(3), side(1))!);
|
||||
repo.git('add', 'CHANGELOG.md');
|
||||
}
|
||||
}
|
||||
const index = repo.git('write-tree');
|
||||
const stamp = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], fx.env);
|
||||
expect(stamp.status, stamp.err).toBe(EXIT.ok);
|
||||
const files = line(stamp.out, 'FILES:').slice('FILES: '.length).split(' ').filter(Boolean);
|
||||
if (o.dropStamp) repo.git('read-tree', index);
|
||||
else repo.git('add', '-A', '--', ...files);
|
||||
repo.git('commit', '-q', '--no-edit', '-m', `slot stamp #${pr}`);
|
||||
if (o.dropStamp) repo.git('checkout', '-q', '--', '.');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/${pr}/head`);
|
||||
return head;
|
||||
}
|
||||
const markMerged = (fx: TrainFixture, pr: string) => fx.write(d => { (d.prs[pr] as any).state = 'MERGED'; });
|
||||
|
||||
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
|
||||
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
|
||||
const fx = make();
|
||||
@@ -794,21 +822,24 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord);
|
||||
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
const pre1 = v('1');
|
||||
expect(pre1.status).toBe(EXIT.ok);
|
||||
expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`);
|
||||
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
const early = v('2');
|
||||
expect(early.status).toBe(EXIT.fail);
|
||||
expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
|
||||
markMerged(fx, '1');
|
||||
const post1 = v('1');
|
||||
expect(post1.status).toBe(EXIT.ok);
|
||||
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m);
|
||||
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m);
|
||||
expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`);
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
|
||||
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' });
|
||||
const mismatch = v('2');
|
||||
@@ -836,8 +867,16 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)');
|
||||
expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1);
|
||||
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`);
|
||||
// The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand.
|
||||
fx.repo.git('checkout', '-q', '-B', 'lane3', h3);
|
||||
fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n');
|
||||
fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n');
|
||||
fx.repo.git('commit', '-qam', 'slot stamp #3');
|
||||
const s3 = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`);
|
||||
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`);
|
||||
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
|
||||
markMerged(fx, '3');
|
||||
const cleared = v('3', rn);
|
||||
expect(cleared.status).toBe(EXIT.ok);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m);
|
||||
@@ -852,14 +891,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'lane1', h1);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
const stamped = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`);
|
||||
const stamped = laneStamp(fx, '1', h1);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`);
|
||||
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`);
|
||||
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
|
||||
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
|
||||
@@ -900,6 +935,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
|
||||
const { repo } = fx;
|
||||
@@ -909,6 +945,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
repo.git('rm', '-rq', '.');
|
||||
repo.git('checkout', t.prefixes[2].commit, '--', '.');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n');
|
||||
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
|
||||
repo.git('add', '-A');
|
||||
const tree = repo.git('write-tree');
|
||||
@@ -991,8 +1028,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
|
||||
// Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed).
|
||||
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
|
||||
// Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent.
|
||||
@@ -1016,6 +1055,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
// The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue.
|
||||
const main2 = publishMain(fx, laneHead, '1.2.5.0');
|
||||
expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`));
|
||||
markMerged(fx, '2');
|
||||
const post = v('2');
|
||||
expect([post.status, post.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`);
|
||||
@@ -1033,8 +1073,10 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n';
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
// Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote.
|
||||
const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
|
||||
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled });
|
||||
@@ -1116,6 +1158,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
|
||||
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
|
||||
markMerged(fx, '3');
|
||||
const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord);
|
||||
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
|
||||
expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`);
|
||||
@@ -1130,6 +1173,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
|
||||
fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; });
|
||||
@@ -1162,6 +1206,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
|
||||
const tree = fx.repo.git('rev-parse', `${later}^{tree}`);
|
||||
@@ -1186,6 +1231,7 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n';
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' });
|
||||
const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
|
||||
@@ -1198,3 +1244,129 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => {
|
||||
/** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */
|
||||
function unstampedMain(fx: TrainFixture, entry: string): string {
|
||||
const { repo } = fx;
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`));
|
||||
repo.git('commit', '-qam', 'merged without its stamp');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
|
||||
|
||||
test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => {
|
||||
const fx = make();
|
||||
const { h1, h2, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
|
||||
const bare = v('1');
|
||||
expect(bare.status).toBe(EXIT.refused);
|
||||
expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
expect(bare.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/);
|
||||
|
||||
const s1 = laneStamp(fx, '1', h1);
|
||||
const ok = v('1');
|
||||
expect(ok.status, ok.err).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0');
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
|
||||
|
||||
laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true });
|
||||
const dropped = v('2');
|
||||
expect(dropped.status, dropped.err).toBe(EXIT.refused);
|
||||
expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
expect(dropped.out).not.toContain('MERGE_CONDITION:');
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
|
||||
const stamped = v('2');
|
||||
expect(stamped.status, stamped.err).toBe(EXIT.ok);
|
||||
expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0');
|
||||
expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
|
||||
}, 300_000);
|
||||
|
||||
test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => {
|
||||
const fx = make();
|
||||
const { h1, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', '-B', 'lane1', h1);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'VERSION only');
|
||||
repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`);
|
||||
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/);
|
||||
}, 300_000);
|
||||
|
||||
test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => {
|
||||
const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' });
|
||||
const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
|
||||
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
|
||||
}, 300_000);
|
||||
|
||||
test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => {
|
||||
const fx = make();
|
||||
unstampedMain(fx, '- #3527 change');
|
||||
const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') });
|
||||
readyReceipt(fx, '5', h5);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo));
|
||||
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m);
|
||||
expect(r.status, r.err).toBe(EXIT.ok);
|
||||
expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`);
|
||||
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`);
|
||||
expect(trainJson(fx, nonce).merged).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => {
|
||||
const fx = make();
|
||||
const { h1, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
laneStamp(fx, '1', h1);
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
markMerged(fx, '1');
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
publishMain(fx, t.prefixes[2].commit, '1.2.5.0');
|
||||
|
||||
const open = v('2');
|
||||
expect(open.status).toBe(EXIT.refused);
|
||||
expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
|
||||
expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
||||
expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED');
|
||||
const saved = fx.read().prs['2'];
|
||||
fx.write(d => { delete d.prs['2']; });
|
||||
const unread = v('2');
|
||||
expect(unread.err).toContain('(QUEUE_PROOF_MISSING)');
|
||||
expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
|
||||
expect(fs.existsSync(freezeFile(fx))).toBe(false);
|
||||
fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; });
|
||||
const done = v('2');
|
||||
expect(done.status, done.err).toBe(EXIT.ok);
|
||||
expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in new issue
Block a user