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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +67 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +24 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +31 -1
- package/dist/core/CoherenceJournal.d.ts.map +1 -1
- package/dist/core/CoherenceJournal.js +49 -2
- package/dist/core/CoherenceJournal.js.map +1 -1
- package/dist/core/JournalSyncApplier.d.ts.map +1 -1
- package/dist/core/JournalSyncApplier.js +18 -0
- package/dist/core/JournalSyncApplier.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +44 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +5 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +27 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/GreenPrAutoMerger.d.ts +188 -0
- package/dist/monitoring/GreenPrAutoMerger.d.ts.map +1 -0
- package/dist/monitoring/GreenPrAutoMerger.js +393 -0
- package/dist/monitoring/GreenPrAutoMerger.js.map +1 -0
- package/dist/monitoring/GuardLatchStore.d.ts +134 -0
- package/dist/monitoring/GuardLatchStore.d.ts.map +1 -0
- package/dist/monitoring/GuardLatchStore.js +255 -0
- package/dist/monitoring/GuardLatchStore.js.map +1 -0
- package/dist/monitoring/MergeRunner.d.ts +126 -0
- package/dist/monitoring/MergeRunner.d.ts.map +1 -0
- package/dist/monitoring/MergeRunner.js +296 -0
- package/dist/monitoring/MergeRunner.js.map +1 -0
- package/dist/monitoring/floorDriftCanary.d.ts +88 -0
- package/dist/monitoring/floorDriftCanary.d.ts.map +1 -0
- package/dist/monitoring/floorDriftCanary.js +89 -0
- package/dist/monitoring/floorDriftCanary.js.map +1 -0
- package/dist/monitoring/greenPrAutomergeWiring.d.ts +73 -0
- package/dist/monitoring/greenPrAutomergeWiring.d.ts.map +1 -0
- package/dist/monitoring/greenPrAutomergeWiring.js +273 -0
- package/dist/monitoring/greenPrAutomergeWiring.js.map +1 -0
- package/dist/monitoring/greenPrLogic.d.ts +121 -0
- package/dist/monitoring/greenPrLogic.d.ts.map +1 -0
- package/dist/monitoring/greenPrLogic.js +198 -0
- package/dist/monitoring/greenPrLogic.js.map +1 -0
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +12 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/server/AgentServer.d.ts +2 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +16 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +112 -1
- package/dist/server/routes.js.map +1 -1
- package/dist/server/stopGate.d.ts +45 -0
- package/dist/server/stopGate.d.ts.map +1 -1
- package/dist/server/stopGate.js +74 -0
- package/dist/server/stopGate.js.map +1 -1
- package/package.json +1 -1
- package/scripts/safe-merge.mjs +449 -59
- package/src/data/builtin-manifest.json +64 -64
- package/upgrades/1.3.512.md +65 -0
- package/upgrades/side-effects/green-pr-automerge-enforcement.md +171 -0
package/scripts/safe-merge.mjs
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// gh invocation layer (sync is fine — this is a standalone CLI, not server code)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
21
227
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
246
|
+
function fetchChecksRows(pr, repo) {
|
|
34
247
|
// gh pr checks exits non-zero when checks are failing/pending; capture either way.
|
|
35
|
-
const r =
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
311
|
+
|
|
312
|
+
function result(obj) {
|
|
313
|
+
console.log(`safe-merge-result: ${JSON.stringify(obj)}`);
|
|
77
314
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
process.
|
|
315
|
+
|
|
316
|
+
function sleep(ms) {
|
|
317
|
+
spawnSync(process.platform === 'win32' ? 'timeout' : 'sleep', [String(Math.ceil(ms / 1000))]);
|
|
81
318
|
}
|
|
82
319
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|