instar 1.3.511 → 1.3.512

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +67 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +24 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CoherenceJournal.d.ts +31 -1
  8. package/dist/core/CoherenceJournal.d.ts.map +1 -1
  9. package/dist/core/CoherenceJournal.js +49 -2
  10. package/dist/core/CoherenceJournal.js.map +1 -1
  11. package/dist/core/JournalSyncApplier.d.ts.map +1 -1
  12. package/dist/core/JournalSyncApplier.js +18 -0
  13. package/dist/core/JournalSyncApplier.js.map +1 -1
  14. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  15. package/dist/core/PostUpdateMigrator.js +44 -0
  16. package/dist/core/PostUpdateMigrator.js.map +1 -1
  17. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  18. package/dist/core/devGatedFeatures.js +5 -0
  19. package/dist/core/devGatedFeatures.js.map +1 -1
  20. package/dist/core/types.d.ts +27 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/monitoring/GreenPrAutoMerger.d.ts +188 -0
  24. package/dist/monitoring/GreenPrAutoMerger.d.ts.map +1 -0
  25. package/dist/monitoring/GreenPrAutoMerger.js +393 -0
  26. package/dist/monitoring/GreenPrAutoMerger.js.map +1 -0
  27. package/dist/monitoring/GuardLatchStore.d.ts +134 -0
  28. package/dist/monitoring/GuardLatchStore.d.ts.map +1 -0
  29. package/dist/monitoring/GuardLatchStore.js +255 -0
  30. package/dist/monitoring/GuardLatchStore.js.map +1 -0
  31. package/dist/monitoring/MergeRunner.d.ts +126 -0
  32. package/dist/monitoring/MergeRunner.d.ts.map +1 -0
  33. package/dist/monitoring/MergeRunner.js +296 -0
  34. package/dist/monitoring/MergeRunner.js.map +1 -0
  35. package/dist/monitoring/floorDriftCanary.d.ts +88 -0
  36. package/dist/monitoring/floorDriftCanary.d.ts.map +1 -0
  37. package/dist/monitoring/floorDriftCanary.js +89 -0
  38. package/dist/monitoring/floorDriftCanary.js.map +1 -0
  39. package/dist/monitoring/greenPrAutomergeWiring.d.ts +73 -0
  40. package/dist/monitoring/greenPrAutomergeWiring.d.ts.map +1 -0
  41. package/dist/monitoring/greenPrAutomergeWiring.js +273 -0
  42. package/dist/monitoring/greenPrAutomergeWiring.js.map +1 -0
  43. package/dist/monitoring/greenPrLogic.d.ts +121 -0
  44. package/dist/monitoring/greenPrLogic.d.ts.map +1 -0
  45. package/dist/monitoring/greenPrLogic.js +198 -0
  46. package/dist/monitoring/greenPrLogic.js.map +1 -0
  47. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  48. package/dist/monitoring/guardManifest.js +12 -0
  49. package/dist/monitoring/guardManifest.js.map +1 -1
  50. package/dist/server/AgentServer.d.ts +2 -0
  51. package/dist/server/AgentServer.d.ts.map +1 -1
  52. package/dist/server/AgentServer.js +2 -0
  53. package/dist/server/AgentServer.js.map +1 -1
  54. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  55. package/dist/server/CapabilityIndex.js +16 -0
  56. package/dist/server/CapabilityIndex.js.map +1 -1
  57. package/dist/server/routes.d.ts +5 -0
  58. package/dist/server/routes.d.ts.map +1 -1
  59. package/dist/server/routes.js +112 -1
  60. package/dist/server/routes.js.map +1 -1
  61. package/dist/server/stopGate.d.ts +45 -0
  62. package/dist/server/stopGate.d.ts.map +1 -1
  63. package/dist/server/stopGate.js +74 -0
  64. package/dist/server/stopGate.js.map +1 -1
  65. package/package.json +1 -1
  66. package/scripts/safe-merge.mjs +449 -59
  67. package/src/data/builtin-manifest.json +64 -64
  68. package/upgrades/1.3.512.md +65 -0
  69. package/upgrades/side-effects/green-pr-automerge-enforcement.md +171 -0
@@ -5,83 +5,473 @@
5
5
  * Why: we merge with `gh pr merge --admin` to jump the hot-branch queue, but
6
6
  * `--admin` BYPASSES branch-protection's required-checks enforcement — which is
7
7
  * how PR #381 merged while the `E2E Tests` job was red and turned main red for
8
- * everyone. This wrapper re-imposes the requirement that `--admin` removes:
9
- * it waits for all checks to finish, refuses the merge if ANY check failed (and
10
- * specifically verifies an e2e check ran + passed), and only then merges.
8
+ * everyone. This wrapper re-imposes the requirement that `--admin` removes.
9
+ *
10
+ * Hardened per docs/specs/green-pr-automerge-enforcement.md §3.1:
11
+ * - strict argv (unknown flags REJECTED), `--capabilities` contract probe
12
+ * - `--repo <owner/name>` parameter (default: the historical constant)
13
+ * - JSON checks parsing (no human-output regex; `pending` matched on the
14
+ * bucket field, never on check NAMES)
15
+ * - required-contexts cross-check: union of classic branch protection +
16
+ * rulesets + a CODE-PINNED floor with PRODUCER binding (app slug +
17
+ * workflow file path) — a lookalike job reporting the right name from a
18
+ * tampered workflow does not satisfy the floor. Any fetch/parse failure is
19
+ * a refusal (`refused:contexts-unverifiable`), never a silent degrade.
20
+ * - refuses when unsatisfied required-review protection exists
21
+ * - head pinning: merges with `--match-head-commit <sha>` (provided by the
22
+ * caller or pinned at verification time) — a push in the window refuses
23
+ * - honest exits: classified result line on stdout; null spawn status or
24
+ * signal kill is an error, never success
25
+ * - `--delete-branch` pass-through; `--deadline-ms` so the caller's timeout
26
+ * and the internal wait can never invert (B24)
11
27
  *
12
28
  * Usage: node scripts/safe-merge.mjs <PR#> [--admin] [--squash|--merge|--rebase]
13
- * --admin still allowed (for the BEHIND/hot-branch case) — but ONLY
14
- * reached AFTER this script has confirmed every check is green.
15
- * default merge method: --merge
29
+ * [--repo <owner/name>] [--delete-branch] [--deadline-ms <n>]
30
+ * [--match-head-commit <sha>] [--extra-floor <ctx,ctx>]
31
+ * node scripts/safe-merge.mjs --capabilities
32
+ *
33
+ * Exit codes: 0 merged (independently confirmed) · 1 refused · 2 usage/error
34
+ * 3 already-merged · 4 closed-without-merge
35
+ * The final stdout line is always `safe-merge-result: {...}` with a
36
+ * machine-parseable classification.
37
+ */
38
+
39
+ import { spawnSync } from 'node:child_process';
40
+
41
+ export const CONTRACT_VERSION = 2;
42
+ export const DEFAULT_REPO = 'JKHeadley/instar';
43
+ export const DEFAULT_DEADLINE_MS = 20 * 60 * 1000;
44
+
45
+ /**
46
+ * Code-pinned required-contexts floor (spec §3.1). Each entry is
47
+ * PRODUCER-bound: the check run satisfying it must come from the pinned app
48
+ * slug AND the pinned workflow file path. Config/CLI extension may EXTEND
49
+ * this list, never shrink it. Grounded against .github/workflows/ on
50
+ * 2026-06-12: ci.yml job display names; decision-audit-gate.yml job id
51
+ * `decision-audit`; eli16-pr-gate.yml job id `eli16`.
52
+ * (The matrix-expanded unit shards ride branch protection's own required
53
+ * list, which is unioned in — see evaluateRequiredContexts.)
54
+ */
55
+ export const REQUIRED_CONTEXTS_FLOOR = [
56
+ { context: 'Type Check', workflowPath: '.github/workflows/ci.yml', appSlug: 'github-actions' },
57
+ { context: 'Integration Tests', workflowPath: '.github/workflows/ci.yml', appSlug: 'github-actions' },
58
+ { context: 'E2E Tests', workflowPath: '.github/workflows/ci.yml', appSlug: 'github-actions' },
59
+ { context: 'Build', workflowPath: '.github/workflows/ci.yml', appSlug: 'github-actions' },
60
+ { context: 'decision-audit', workflowPath: '.github/workflows/decision-audit-gate.yml', appSlug: 'github-actions' },
61
+ { context: 'eli16', workflowPath: '.github/workflows/eli16-pr-gate.yml', appSlug: 'github-actions' },
62
+ ];
63
+
64
+ const ALLOWED_FLAGS = new Set([
65
+ '--admin', '--squash', '--merge', '--rebase', '--delete-branch', '--capabilities',
66
+ ]);
67
+ const ALLOWED_VALUE_FLAGS = new Set([
68
+ '--repo', '--deadline-ms', '--match-head-commit', '--extra-floor',
69
+ ]);
70
+
71
+ /** Strict argv parser — unknown flags are REJECTED (spec §3.1: a stale or
72
+ * confused caller must fail loudly, not have its intent silently ignored). */
73
+ export function parseArgs(argv) {
74
+ const out = {
75
+ pr: null, repo: DEFAULT_REPO, method: '--merge', admin: false,
76
+ deleteBranch: false, deadlineMs: DEFAULT_DEADLINE_MS,
77
+ matchHeadCommit: null, extraFloor: [], capabilities: false,
78
+ };
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ if (/^\d+$/.test(a)) {
82
+ if (out.pr !== null) throw new UsageError(`duplicate PR number: ${a}`);
83
+ out.pr = a;
84
+ continue;
85
+ }
86
+ if (ALLOWED_VALUE_FLAGS.has(a)) {
87
+ const v = argv[++i];
88
+ if (v === undefined || v.startsWith('--')) throw new UsageError(`${a} requires a value`);
89
+ if (a === '--repo') {
90
+ if (!/^[\w.-]+\/[\w.-]+$/.test(v)) throw new UsageError(`--repo must be owner/name, got: ${v}`);
91
+ out.repo = v;
92
+ } else if (a === '--deadline-ms') {
93
+ const n = Number(v);
94
+ if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--deadline-ms must be a positive number, got: ${v}`);
95
+ out.deadlineMs = n;
96
+ } else if (a === '--match-head-commit') {
97
+ if (!/^[0-9a-f]{7,40}$/i.test(v)) throw new UsageError(`--match-head-commit must be a commit SHA, got: ${v}`);
98
+ out.matchHeadCommit = v;
99
+ } else if (a === '--extra-floor') {
100
+ out.extraFloor = v.split(',').map(s => s.trim()).filter(Boolean);
101
+ }
102
+ continue;
103
+ }
104
+ if (ALLOWED_FLAGS.has(a)) {
105
+ if (a === '--admin') out.admin = true;
106
+ else if (a === '--delete-branch') out.deleteBranch = true;
107
+ else if (a === '--capabilities') out.capabilities = true;
108
+ else out.method = a; // --squash | --merge | --rebase
109
+ continue;
110
+ }
111
+ throw new UsageError(`unknown flag: ${a} (strict argv — see --capabilities for the contract)`);
112
+ }
113
+ if (!out.capabilities && out.pr === null) throw new UsageError('a PR number is required');
114
+ return out;
115
+ }
116
+
117
+ export class UsageError extends Error {}
118
+
119
+ export function capabilities() {
120
+ return {
121
+ contract: CONTRACT_VERSION,
122
+ features: [
123
+ 'strict-argv', 'repo-param', 'json-checks', 'head-pinning',
124
+ 'required-contexts-cross-check', 'producer-binding', 'floor',
125
+ 'reviews-required-refusal', 'classified-exits', 'delete-branch', 'deadline-ms',
126
+ ],
127
+ exitCodes: { merged: 0, refused: 1, usageOrError: 2, alreadyMerged: 3, closed: 4 },
128
+ };
129
+ }
130
+
131
+ /** Classify `gh pr checks --json` rows. `pending` is matched ONLY on the
132
+ * bucket field — a check NAMED "block-pending-migrations" never loops the
133
+ * wait (the round-1 foundation bug). */
134
+ export function classifyChecks(rows) {
135
+ const pending = [];
136
+ const failed = [];
137
+ let sawE2e = false;
138
+ let e2ePassed = false;
139
+ for (const row of rows) {
140
+ const name = String(row.name ?? '');
141
+ const bucket = String(row.bucket ?? '').toLowerCase();
142
+ if (!name) continue;
143
+ if (bucket === 'pending') { pending.push(name); continue; }
144
+ const ok = bucket === 'pass' || bucket === 'skipping';
145
+ if (/e2e/i.test(name)) {
146
+ sawE2e = true;
147
+ if (bucket === 'pass') e2ePassed = true;
148
+ }
149
+ if (!ok) failed.push(`${name}: ${bucket}`);
150
+ }
151
+ return { pending, failed, sawE2e, e2ePassed, settled: pending.length === 0 };
152
+ }
153
+
154
+ /**
155
+ * Required-contexts cross-check (spec §3.1).
156
+ *
157
+ * The verification set is the UNION of:
158
+ * - classic branch protection `required_status_checks.contexts`
159
+ * - branch-ruleset required_status_checks entries
160
+ * - the code-pinned floor (+ any caller extension)
161
+ * The floor stays enforced even when protection's own list omits it — a
162
+ * confused protection edit (or config write) cannot delete the guarantee.
16
163
  *
17
- * Exit non-zero (and do NOT merge) on any red/failed check.
164
+ * Every context in the set must have a genuinely-successful check run on the
165
+ * verified head (skipped/neutral on a REQUIRED context = refusal). Floor
166
+ * entries additionally require a PRODUCER match: the satisfying run's app
167
+ * slug and workflow file path must equal the pins — name-matching alone is
168
+ * explicitly insufficient.
169
+ *
170
+ * @param {object} args
171
+ * @param {string[]} args.protectionContexts contexts from classic protection
172
+ * @param {string[]} args.rulesetContexts contexts from branch rulesets
173
+ * @param {Array<{context: string, workflowPath?: string, appSlug?: string}>} args.floor
174
+ * @param {string[]} args.extraFloor name-only extension (config/CLI)
175
+ * @param {Array<{name: string, conclusion: string, appSlug: string|null, workflowPath: string|null}>} args.checkRuns
18
176
  */
177
+ export function evaluateRequiredContexts({ protectionContexts, rulesetContexts, floor, extraFloor, checkRuns }) {
178
+ const problems = [];
179
+ const successByName = new Map();
180
+ for (const run of checkRuns) {
181
+ if (String(run.conclusion ?? '').toLowerCase() === 'success') {
182
+ const list = successByName.get(run.name) ?? [];
183
+ list.push(run);
184
+ successByName.set(run.name, list);
185
+ }
186
+ }
187
+ const union = new Set([
188
+ ...protectionContexts,
189
+ ...rulesetContexts,
190
+ ...floor.map(f => f.context),
191
+ ...extraFloor,
192
+ ]);
193
+ for (const ctx of union) {
194
+ if (!successByName.has(ctx)) {
195
+ problems.push(`required context "${ctx}" has no genuinely-successful run`);
196
+ }
197
+ }
198
+ for (const pin of floor) {
199
+ const runs = successByName.get(pin.context) ?? [];
200
+ if (runs.length === 0) continue; // already reported above
201
+ const producerOk = runs.some(r =>
202
+ (!pin.appSlug || r.appSlug === pin.appSlug) &&
203
+ (!pin.workflowPath || r.workflowPath === pin.workflowPath));
204
+ if (!producerOk) {
205
+ problems.push(
206
+ `floor context "${pin.context}" succeeded but from the WRONG producer ` +
207
+ `(want app=${pin.appSlug} workflow=${pin.workflowPath}; ` +
208
+ `got ${runs.map(r => `app=${r.appSlug} workflow=${r.workflowPath}`).join(' | ')})`);
209
+ }
210
+ }
211
+ return { ok: problems.length === 0, problems };
212
+ }
213
+
214
+ /** Classify the merge outcome from gh's stderr/exit (spec §3.1 honest exits). */
215
+ export function classifyMergeFailure(stderrText, status, signal) {
216
+ const text = String(stderrText ?? '');
217
+ if (/already merged/i.test(text)) return 'already-merged';
218
+ if (/(pull request .* is closed|state.*closed|not open)/i.test(text)) return 'closed';
219
+ if (signal) return `error:signal-${signal}`;
220
+ if (status === null) return 'error:null-status';
221
+ return 'error:merge-command-failed';
222
+ }
19
223
 
20
- import { execSync, spawnSync } from 'node:child_process';
224
+ // ---------------------------------------------------------------------------
225
+ // gh invocation layer (sync is fine — this is a standalone CLI, not server code)
226
+ // ---------------------------------------------------------------------------
21
227
 
22
- const REPO = 'JKHeadley/instar';
23
- const args = process.argv.slice(2);
24
- const pr = args.find(a => /^\d+$/.test(a));
25
- const useAdmin = args.includes('--admin');
26
- const method = args.find(a => ['--squash', '--merge', '--rebase'].includes(a)) || '--merge';
228
+ function gh(args, { allowFail = false } = {}) {
229
+ const r = spawnSync('gh', args, { encoding: 'utf-8' });
230
+ if (r.error) throw new Error(`gh spawn failed: ${r.error.message}`);
231
+ if (r.status !== 0 && !allowFail) {
232
+ throw new Error(`gh ${args.slice(0, 3).join(' ')} exited ${r.status}: ${(r.stderr || '').slice(0, 400)}`);
233
+ }
234
+ return r;
235
+ }
27
236
 
28
- if (!pr) {
29
- console.error('usage: node scripts/safe-merge.mjs <PR#> [--admin] [--squash|--merge|--rebase]');
30
- process.exit(2);
237
+ function ghJson(args) {
238
+ const r = gh(args);
239
+ try {
240
+ return JSON.parse(r.stdout);
241
+ } catch {
242
+ throw new Error(`gh ${args.slice(0, 3).join(' ')} returned unparseable JSON`);
243
+ }
31
244
  }
32
245
 
33
- function checks() {
246
+ function fetchChecksRows(pr, repo) {
34
247
  // gh pr checks exits non-zero when checks are failing/pending; capture either way.
35
- const r = spawnSync('gh', ['pr', 'checks', pr, '--repo', REPO], { encoding: 'utf-8' });
36
- return (r.stdout || '') + (r.stderr || '');
248
+ const r = gh(['pr', 'checks', pr, '--repo', repo, '--json', 'name,state,bucket'], { allowFail: true });
249
+ try {
250
+ const rows = JSON.parse(r.stdout);
251
+ if (!Array.isArray(rows)) throw new Error('not an array');
252
+ return rows;
253
+ } catch {
254
+ throw new Error(`gh pr checks returned unparseable JSON: ${(r.stdout || '').slice(0, 200)}`);
255
+ }
37
256
  }
38
257
 
39
- // Poll until nothing is pending (cap ~20 min).
40
- console.log(`safe-merge: waiting for PR #${pr} checks to finish...`);
41
- const deadline = Date.now() + 20 * 60 * 1000;
42
- let out = checks();
43
- while (/\bpending\b/.test(out)) {
44
- if (Date.now() > deadline) {
45
- console.error('safe-merge: timed out waiting for checks. NOT merging.');
46
- process.exit(1);
258
+ function fetchProtectionContexts(repo, branch) {
259
+ const r = gh(['api', `repos/${repo}/branches/${branch}/protection`], { allowFail: true });
260
+ if (r.status !== 0) {
261
+ // 404 = no classic protection configured: an empty contribution to the
262
+ // union (the floor still applies). Anything else is unverifiable.
263
+ if (/HTTP 404/.test(r.stderr || '') || /Branch not protected/i.test(r.stderr || '')) {
264
+ return { contexts: [], reviewsRequired: false };
265
+ }
266
+ throw new Error(`branch protection unverifiable: ${(r.stderr || '').slice(0, 300)}`);
47
267
  }
48
- execSync('sleep 15');
49
- out = checks();
268
+ const data = JSON.parse(r.stdout);
269
+ return {
270
+ contexts: data?.required_status_checks?.contexts ?? [],
271
+ reviewsRequired: data?.required_pull_request_reviews != null,
272
+ };
50
273
  }
51
274
 
52
- // Parse rows: "<name>\t<state>\t<elapsed>\t<url>". States: pass | fail | skipping | ...
53
- const rows = out.split('\n').map(l => l.trim()).filter(Boolean);
54
- const failed = [];
55
- let sawE2e = false;
56
- let e2ePassed = false;
57
- for (const line of rows) {
58
- const cols = line.split('\t').map(c => c.trim());
59
- const name = cols[0] || '';
60
- const state = (cols[1] || '').toLowerCase();
61
- if (!name || !state) continue;
62
- const ok = state === 'pass' || state === 'skipping' || state === 'neutral';
63
- if (/e2e/i.test(name)) {
64
- sawE2e = true;
65
- if (state === 'pass') e2ePassed = true;
66
- }
67
- if (!ok) failed.push(`${name}: ${state}`);
275
+ function fetchRulesetContexts(repo, branch) {
276
+ const r = gh(['api', `repos/${repo}/rules/branches/${branch}`], { allowFail: true });
277
+ if (r.status !== 0) {
278
+ if (/HTTP 404/.test(r.stderr || '')) return [];
279
+ throw new Error(`branch rulesets unverifiable: ${(r.stderr || '').slice(0, 300)}`);
280
+ }
281
+ const rules = JSON.parse(r.stdout);
282
+ const contexts = [];
283
+ for (const rule of Array.isArray(rules) ? rules : []) {
284
+ if (rule?.type === 'required_status_checks') {
285
+ for (const c of rule?.parameters?.required_status_checks ?? []) {
286
+ if (c?.context) contexts.push(c.context);
287
+ }
288
+ }
289
+ }
290
+ return contexts;
68
291
  }
69
292
 
70
- if (failed.length > 0) {
71
- console.error(`safe-merge: REFUSING ${failed.length} check(s) not green:\n ${failed.join('\n ')}`);
72
- process.exit(1);
293
+ function fetchCheckRunsWithProducers(repo, headSha) {
294
+ const runsResp = ghJson(['api', `repos/${repo}/commits/${headSha}/check-runs?per_page=100`, '--paginate', '--slurp']);
295
+ const pages = Array.isArray(runsResp) ? runsResp : [runsResp];
296
+ const checkRunsRaw = pages.flatMap(p => p?.check_runs ?? []);
297
+ const wfResp = ghJson(['api', `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`, '--paginate', '--slurp']);
298
+ const wfPages = Array.isArray(wfResp) ? wfResp : [wfResp];
299
+ const workflowRuns = wfPages.flatMap(p => p?.workflow_runs ?? []);
300
+ const pathBySuite = new Map();
301
+ for (const wr of workflowRuns) {
302
+ if (wr?.check_suite_id != null && wr?.path) pathBySuite.set(wr.check_suite_id, wr.path);
303
+ }
304
+ return checkRunsRaw.map(cr => ({
305
+ name: cr?.name ?? '',
306
+ conclusion: cr?.conclusion ?? '',
307
+ appSlug: cr?.app?.slug ?? null,
308
+ workflowPath: pathBySuite.get(cr?.check_suite?.id) ?? null,
309
+ }));
73
310
  }
74
- if (!sawE2e) {
75
- console.error('safe-merge: REFUSING — no e2e check found in the PR checks. The e2e job is the one --admin bypasses; do not merge without it. (Override only with explicit human sign-off.)');
76
- process.exit(1);
311
+
312
+ function result(obj) {
313
+ console.log(`safe-merge-result: ${JSON.stringify(obj)}`);
77
314
  }
78
- if (!e2ePassed) {
79
- console.error('safe-merge: REFUSING — an e2e check is present but did not report pass.');
80
- process.exit(1);
315
+
316
+ function sleep(ms) {
317
+ spawnSync(process.platform === 'win32' ? 'timeout' : 'sleep', [String(Math.ceil(ms / 1000))]);
81
318
  }
82
319
 
83
- console.log(`safe-merge: all checks green (e2e confirmed). Merging PR #${pr} (${method}${useAdmin ? ' --admin' : ''})...`);
84
- const mergeArgs = ['pr', 'merge', pr, '--repo', REPO, method];
85
- if (useAdmin) mergeArgs.push('--admin');
86
- const m = spawnSync('gh', mergeArgs, { stdio: 'inherit', encoding: 'utf-8' });
87
- process.exit(m.status ?? 0);
320
+ // ---------------------------------------------------------------------------
321
+ // main
322
+ // ---------------------------------------------------------------------------
323
+
324
+ async function main() {
325
+ let args;
326
+ try {
327
+ args = parseArgs(process.argv.slice(2));
328
+ } catch (e) {
329
+ if (e instanceof UsageError) {
330
+ console.error(`safe-merge: ${e.message}`);
331
+ console.error('usage: node scripts/safe-merge.mjs <PR#> [--admin] [--squash|--merge|--rebase] [--repo <owner/name>] [--delete-branch] [--deadline-ms <n>] [--match-head-commit <sha>] [--extra-floor <ctx,ctx>]');
332
+ result({ result: 'error:usage', detail: e.message });
333
+ process.exit(2);
334
+ }
335
+ throw e;
336
+ }
337
+
338
+ if (args.capabilities) {
339
+ console.log(JSON.stringify(capabilities()));
340
+ process.exit(0);
341
+ }
342
+
343
+ const { pr, repo } = args;
344
+ const deadline = Date.now() + args.deadlineMs;
345
+
346
+ // --- PR state + head pin -------------------------------------------------
347
+ let view;
348
+ try {
349
+ view = ghJson(['pr', 'view', pr, '--repo', repo, '--json', 'state,mergedAt,headRefOid,baseRefName,reviewDecision,isDraft']);
350
+ } catch (e) {
351
+ result({ result: 'error:pr-unreadable', detail: String(e.message).slice(0, 300) });
352
+ process.exit(2);
353
+ }
354
+ if (view.state === 'MERGED' || view.mergedAt) {
355
+ result({ result: 'already-merged', pr: Number(pr) });
356
+ process.exit(3);
357
+ }
358
+ if (view.state === 'CLOSED') {
359
+ result({ result: 'closed', pr: Number(pr) });
360
+ process.exit(4);
361
+ }
362
+ if (view.isDraft) {
363
+ result({ result: 'refused:draft', pr: Number(pr) });
364
+ process.exit(1);
365
+ }
366
+ const pinnedHead = args.matchHeadCommit ?? view.headRefOid;
367
+ if (args.matchHeadCommit && view.headRefOid && args.matchHeadCommit !== view.headRefOid) {
368
+ result({ result: 'refused:head-moved', pr: Number(pr), expected: args.matchHeadCommit, actual: view.headRefOid });
369
+ console.error('safe-merge: REFUSING — the PR head moved past the verified commit.');
370
+ process.exit(1);
371
+ }
372
+
373
+ // --- Wait for checks to settle (bucket-based, never name-based) ----------
374
+ console.log(`safe-merge: waiting for PR #${pr} checks to finish (deadline ${Math.round(args.deadlineMs / 1000)}s)...`);
375
+ let checksState;
376
+ for (;;) {
377
+ let rows;
378
+ try {
379
+ rows = fetchChecksRows(pr, repo);
380
+ } catch (e) {
381
+ result({ result: 'refused:contexts-unverifiable', detail: String(e.message).slice(0, 300) });
382
+ console.error(`safe-merge: REFUSING — ${e.message}`);
383
+ process.exit(1);
384
+ }
385
+ checksState = classifyChecks(rows);
386
+ if (checksState.settled) break;
387
+ if (Date.now() > deadline) {
388
+ result({ result: 'refused:checks-timeout', pending: checksState.pending.slice(0, 10) });
389
+ console.error('safe-merge: timed out waiting for checks. NOT merging.');
390
+ process.exit(1);
391
+ }
392
+ sleep(15_000);
393
+ }
394
+
395
+ if (checksState.failed.length > 0) {
396
+ result({ result: 'refused:red-checks', failed: checksState.failed.slice(0, 20) });
397
+ console.error(`safe-merge: REFUSING — ${checksState.failed.length} check(s) not green:\n ${checksState.failed.join('\n ')}`);
398
+ process.exit(1);
399
+ }
400
+ if (!checksState.sawE2e || !checksState.e2ePassed) {
401
+ result({ result: 'refused:e2e-missing-or-red' });
402
+ console.error('safe-merge: REFUSING — the e2e check is missing or did not pass. The e2e job is the one --admin bypasses; do not merge without it.');
403
+ process.exit(1);
404
+ }
405
+
406
+ // --- Required-contexts cross-check with producer-bound floor --------------
407
+ let evaluation;
408
+ try {
409
+ const protection = fetchProtectionContexts(repo, view.baseRefName || 'main');
410
+ if (protection.reviewsRequired && view.reviewDecision !== 'APPROVED') {
411
+ result({ result: 'refused:reviews-required', reviewDecision: view.reviewDecision ?? null });
412
+ console.error('safe-merge: REFUSING — required-review protection exists and is unsatisfied. --admin would bypass it un-re-imposed.');
413
+ process.exit(1);
414
+ }
415
+ const rulesetContexts = fetchRulesetContexts(repo, view.baseRefName || 'main');
416
+ const checkRuns = fetchCheckRunsWithProducers(repo, pinnedHead);
417
+ evaluation = evaluateRequiredContexts({
418
+ protectionContexts: protection.contexts,
419
+ rulesetContexts,
420
+ floor: REQUIRED_CONTEXTS_FLOOR,
421
+ extraFloor: args.extraFloor,
422
+ checkRuns,
423
+ });
424
+ } catch (e) {
425
+ result({ result: 'refused:contexts-unverifiable', detail: String(e.message).slice(0, 300) });
426
+ console.error(`safe-merge: REFUSING — required-contexts verification failed: ${e.message}`);
427
+ process.exit(1);
428
+ }
429
+ if (!evaluation.ok) {
430
+ result({ result: 'refused:contexts', problems: evaluation.problems.slice(0, 20) });
431
+ console.error(`safe-merge: REFUSING — required-contexts cross-check failed:\n ${evaluation.problems.join('\n ')}`);
432
+ process.exit(1);
433
+ }
434
+
435
+ // --- Merge (head-pinned) ---------------------------------------------------
436
+ console.log(`safe-merge: all checks green (e2e + producer-bound floor confirmed). Merging PR #${pr} (${args.method}${args.admin ? ' --admin' : ''}, head ${pinnedHead.slice(0, 12)})...`);
437
+ const mergeArgs = ['pr', 'merge', pr, '--repo', repo, args.method, '--match-head-commit', pinnedHead];
438
+ if (args.admin) mergeArgs.push('--admin');
439
+ if (args.deleteBranch) mergeArgs.push('--delete-branch');
440
+ const m = spawnSync('gh', mergeArgs, { encoding: 'utf-8' });
441
+ process.stdout.write(m.stdout ?? '');
442
+ process.stderr.write(m.stderr ?? '');
443
+
444
+ if (m.status !== 0 || m.signal) {
445
+ const cls = classifyMergeFailure(m.stderr, m.status, m.signal);
446
+ result({ result: cls === 'already-merged' || cls === 'closed' ? cls : `refused-or-${cls}`, raw: cls });
447
+ if (cls === 'already-merged') process.exit(3);
448
+ if (cls === 'closed') process.exit(4);
449
+ console.error(`safe-merge: merge command did not succeed (${cls}).`);
450
+ process.exit(2);
451
+ }
452
+
453
+ // --- Independent confirmation (B10: never trust the exit code alone) ------
454
+ try {
455
+ const after = ghJson(['pr', 'view', pr, '--repo', repo, '--json', 'state,mergedAt']);
456
+ if (after.state === 'MERGED' || after.mergedAt) {
457
+ result({ result: 'merged', pr: Number(pr), head: pinnedHead });
458
+ process.exit(0);
459
+ }
460
+ result({ result: 'error:merge-unconfirmed', state: after.state });
461
+ console.error('safe-merge: gh reported success but the PR is not MERGED on re-read. NOT claiming success.');
462
+ process.exit(2);
463
+ } catch (e) {
464
+ result({ result: 'error:confirm-unreadable', detail: String(e.message).slice(0, 300) });
465
+ console.error('safe-merge: could not independently confirm the merge. NOT claiming success.');
466
+ process.exit(2);
467
+ }
468
+ }
469
+
470
+ const isCliEntry = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
471
+ if (isCliEntry) {
472
+ main().catch(e => {
473
+ console.error(`safe-merge: unexpected error: ${e?.stack || e}`);
474
+ result({ result: 'error:unexpected', detail: String(e?.message ?? e).slice(0, 300) });
475
+ process.exit(2);
476
+ });
477
+ }