ruvnet-brain 4.7.21 → 4.7.23
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/README.md +2 -2
- package/bin/install.mjs +126 -9
- package/console/activity.js +122 -38
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +34 -12
- package/plugin/scripts/completion-claim-evidence.mjs +185 -11
- package/plugin/scripts/continuation-gate.mjs +52 -7
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +1 -1
- package/plugin/scripts/session-start-core.mjs +2 -1
- package/plugin/scripts/session-start-health.mjs +3 -2
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/onboarding-console.mjs +76 -15
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +41 -0
- package/scripts/wired-check.mjs +13 -5
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// S7-S12 of the autofix pipeline (ADR-107): push the release branch, wait for preflight on the EXACT SHA, open the PR
|
|
2
|
+
// for its required checks, re-check the corpus generation, move main by fast-forward only, dispatch the protected
|
|
3
|
+
// release, watch every job, then check the terminal state. Every external call goes through ctx.exec.
|
|
4
|
+
//
|
|
5
|
+
// RESUMABLE. Each step records itself in a journal (ctx.saveJournal) and first looks for its own effect on GitHub, so a
|
|
6
|
+
// run killed by a network drop, Mac sleep or the job's wall clock is finished by the next run without a second push,
|
|
7
|
+
// a second PR or a second dispatch. The kill switch is re-read (ctx.checkpoint) immediately before every irreversible
|
|
8
|
+
// step: push, PR create, main fast-forward, dispatch, recovery dispatch.
|
|
9
|
+
//
|
|
10
|
+
// HALT only on a definite red once main has moved (a concluded red release run, a terminal check that fails after the
|
|
11
|
+
// run concluded green). A transient failure (gh or network unreachable, a watch that timed out, the kill switch
|
|
12
|
+
// unreadable or off) keeps the journal and returns phase 'paused': the next armed run resumes it.
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { AutofixAbort } from './issue-autofix-policy.mjs';
|
|
15
|
+
import {
|
|
16
|
+
MIN, SHA_RE, tail, must, git, mustGit, gh, ghJson, ghBin, assertCleanText, assertVersion,
|
|
17
|
+
poll, queryNpmLatest, queryReleasesLatest, queryRemoteMain, resolveRuntime,
|
|
18
|
+
} from './issue-autofix-release-util.mjs';
|
|
19
|
+
|
|
20
|
+
const POLL_MS = 30_000;
|
|
21
|
+
const PREFLIGHT_WORKFLOW = 'release-candidate-preflight.yml';
|
|
22
|
+
const RELEASE_WORKFLOW = 'protected-release.yml';
|
|
23
|
+
const OK_JOB = new Set(['success', 'skipped']);
|
|
24
|
+
const TRANSIENT = new Set(['transient', 'kill-switch-off', 'halted', 'preflight-timeout', 'window-closed']);
|
|
25
|
+
|
|
26
|
+
/** Terminal install-verified check; also used by S13b for an already-published tag. */
|
|
27
|
+
export async function verifyTerminal(ctx, { version, sha }) {
|
|
28
|
+
const out = path.join(ctx.runDir || '.', `approved-runtime-${version}.json`);
|
|
29
|
+
const runtimeResolved = await resolveRuntime(ctx, version, out);
|
|
30
|
+
const npmLatest = await queryNpmLatest(ctx);
|
|
31
|
+
const releasesLatest = await queryReleasesLatest(ctx);
|
|
32
|
+
const mainSha = await queryRemoteMain(ctx);
|
|
33
|
+
const installVerified = runtimeResolved && npmLatest === version && releasesLatest === `v${version}` && mainSha === sha;
|
|
34
|
+
return { installVerified, npmLatest, releasesLatest, mainSha, runtimeResolved };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const transient = (stage, detail) => new AutofixAbort(stage, 'transient', detail);
|
|
38
|
+
|
|
39
|
+
async function remoteBranchSha(ctx, branch) {
|
|
40
|
+
const r = await git(ctx, ctx.clone, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`]);
|
|
41
|
+
if (r.status !== 0 || r.timedOut) throw transient('S7', `cannot read remote ${branch}`);
|
|
42
|
+
return r.stdout.trim().split(/\s+/)[0] || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function stepPush(ctx, j, rwt) {
|
|
46
|
+
const there = await remoteBranchSha(ctx, j.branch);
|
|
47
|
+
if (there === j.sha) return;
|
|
48
|
+
if (there) throw new AutofixAbort('S7', 'branch-exists', `${j.branch} is at ${there} on the remote, not ${j.sha}`);
|
|
49
|
+
await ctx.checkpoint('S7');
|
|
50
|
+
const r = await git(ctx, rwt, ['push', 'origin', j.branch]);
|
|
51
|
+
if (r.status !== 0 || r.timedOut) {
|
|
52
|
+
if (await remoteBranchSha(ctx, j.branch) === j.sha) return; // the push landed; only the reply was lost
|
|
53
|
+
throw transient('S7', `push: ${tail(r.stderr || r.stdout, 300)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function stepPreflight(ctx, j) {
|
|
58
|
+
const list = ['run', 'list', '--repo', ctx.repo, '--workflow', PREFLIGHT_WORKFLOW, '--branch', j.branch,
|
|
59
|
+
'--json', 'databaseId,headSha,status,conclusion', '--limit', '20'];
|
|
60
|
+
const found = await poll(ctx, { limitMs: 10 * MIN, intervalMs: POLL_MS, fn: async () => {
|
|
61
|
+
const r = await ghJson(ctx, list);
|
|
62
|
+
return Array.isArray(r.value) ? r.value.find((x) => x.headSha === j.sha) || null : null;
|
|
63
|
+
} });
|
|
64
|
+
if (!found) throw new AutofixAbort('S8', 'preflight-timeout', `no preflight run for ${j.sha} within 10 minutes`);
|
|
65
|
+
j.preflightRunId = found.databaseId; ctx.saveJournal(j);
|
|
66
|
+
if (found.status !== 'completed') {
|
|
67
|
+
const w = await gh(ctx, ['run', 'watch', String(found.databaseId), '--repo', ctx.repo, '--exit-status'], { timeoutMs: 60 * MIN });
|
|
68
|
+
if (w.timedOut) throw transient('S8', `preflight ${found.databaseId} still running after 60 minutes`);
|
|
69
|
+
}
|
|
70
|
+
const v = await ghJson(ctx, ['run', 'view', String(found.databaseId), '--repo', ctx.repo, '--json', 'status,conclusion,headSha']);
|
|
71
|
+
if (!v.ok) throw transient('S8', `cannot read preflight ${found.databaseId}`);
|
|
72
|
+
if (v.value.status !== 'completed') throw transient('S8', `preflight ${found.databaseId} not finished`);
|
|
73
|
+
if (v.value.conclusion !== 'success' || v.value.headSha !== j.sha) {
|
|
74
|
+
throw new AutofixAbort('S8', 'preflight-failed', `run ${found.databaseId}: conclusion ${v.value.conclusion}, sha ${v.value.headSha}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function stepPr(ctx, j) {
|
|
79
|
+
if (!j.prNumber) {
|
|
80
|
+
const open = await ghJson(ctx, ['pr', 'list', '--repo', ctx.repo, '--head', j.branch, '--state', 'open', '--json', 'number', '--limit', '5']);
|
|
81
|
+
if (!open.ok) throw transient('S9', 'cannot list pull requests');
|
|
82
|
+
if (open.value.length) j.prNumber = open.value[0].number;
|
|
83
|
+
else {
|
|
84
|
+
const title = `release ${j.version}: autofix issues ${j.issues.join(', ')}`;
|
|
85
|
+
const body = `Refs issues ${j.issues.join(', ')} (ADR-107 autofix). No merge button is used; main moves by fast-forward.`;
|
|
86
|
+
assertCleanText('S9', `${title}\n${body}`);
|
|
87
|
+
await ctx.checkpoint('S9');
|
|
88
|
+
const created = await gh(ctx, ['pr', 'create', '--repo', ctx.repo, '--base', 'main', '--head', j.branch, '--title', title, '--body', body]);
|
|
89
|
+
const m = String(created.stdout).match(/\/pull\/(\d+)/);
|
|
90
|
+
if (created.status !== 0 || !m) throw transient('S9', `pr create: ${tail(created.stderr || created.stdout, 300)}`);
|
|
91
|
+
j.prNumber = Number(m[1]);
|
|
92
|
+
}
|
|
93
|
+
ctx.saveJournal(j);
|
|
94
|
+
}
|
|
95
|
+
const c = await gh(ctx, ['pr', 'checks', String(j.prNumber), '--repo', ctx.repo, '--required', '--watch', '--fail-fast'], { timeoutMs: 30 * MIN });
|
|
96
|
+
if (c.timedOut) throw transient('S9', 'required checks not finished in 30 minutes');
|
|
97
|
+
if (c.status !== 0) throw new AutofixAbort('S9', 'checks-failed', tail(c.stdout || c.stderr, 400));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** release:prepush --pre-dispatch: the sealed corpus generation must still be the newest one, BEFORE main moves. */
|
|
101
|
+
async function stepPreDispatch(ctx, j, rwt) {
|
|
102
|
+
if (ctx.gates?.['release-prepush-dispatch']) {
|
|
103
|
+
const g = await ctx.gates['release-prepush-dispatch'](rwt);
|
|
104
|
+
if (g.exitCode !== 0) throw new AutofixAbort('S9', 'prepush-failed', tail(g.output, 600));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const r = await ctx.exec('npm', ['run', 'release:prepush', '--', '--pre-dispatch', '--sha', j.sha], { cwd: rwt, timeoutMs: 20 * MIN });
|
|
108
|
+
if (r.timedOut) throw transient('S9', 'release:prepush --pre-dispatch timed out');
|
|
109
|
+
if (r.status !== 0) throw new AutofixAbort('S9', 'prepush-failed', tail(`${r.stdout}${r.stderr}`, 600));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function stepPromote(ctx, j, rwt) {
|
|
113
|
+
const remote = await queryRemoteMain(ctx, rwt);
|
|
114
|
+
if (!remote) throw transient('S10', 'cannot read remote main');
|
|
115
|
+
if (remote === j.sha) { j.moved = true; ctx.saveJournal(j); return; }
|
|
116
|
+
await mustGit(ctx, 'S10', 'transient', rwt, ['fetch', 'origin', 'main']);
|
|
117
|
+
const anc = await git(ctx, rwt, ['merge-base', '--is-ancestor', 'origin/main', j.sha]);
|
|
118
|
+
if (anc.status === 1) throw new AutofixAbort('S10', 'not-ancestor', 'origin/main is not an ancestor of the candidate; main moved');
|
|
119
|
+
if (anc.status !== 0) throw transient('S10', tail(anc.stderr, 300));
|
|
120
|
+
await ctx.checkpoint('S10'); // kill switch + release window, immediately before the irreversible step
|
|
121
|
+
const push = await git(ctx, rwt, ['push', 'origin', `${j.sha}:refs/heads/main`]);
|
|
122
|
+
const after = await queryRemoteMain(ctx, rwt);
|
|
123
|
+
if (after === j.sha) { j.moved = true; j.movedAt = ctx.now(); ctx.saveJournal(j); return; }
|
|
124
|
+
// The push said yes but the remote names another commit: main may have moved under us. Treat it as moved (halt).
|
|
125
|
+
if (push.status === 0) { j.moved = true; j.movedAt = ctx.now(); ctx.saveJournal(j); throw new AutofixAbort('S10', 'main-moved', `remote main is ${after} after a successful push`); }
|
|
126
|
+
throw transient('S10', `push exit ${push.status}: ${tail(push.stderr, 300)}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function findReleaseRun(ctx, j) {
|
|
130
|
+
const r = await ghJson(ctx, ['run', 'list', '--repo', ctx.repo, '--workflow', RELEASE_WORKFLOW,
|
|
131
|
+
'--json', 'databaseId,displayTitle,createdAt,status,conclusion,headSha', '--limit', '30']);
|
|
132
|
+
if (!r.ok) throw transient('S11', 'cannot list protected-release runs');
|
|
133
|
+
const title = `protected-release code ${j.version}`;
|
|
134
|
+
return r.value.find((x) => x.displayTitle === title && Date.parse(x.createdAt) >= (j.dispatchedAt || j.movedAt || 0) - 120_000) || null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function stepDispatch(ctx, j) {
|
|
138
|
+
let run = j.releaseRunId ? { databaseId: j.releaseRunId } : await findReleaseRun(ctx, j);
|
|
139
|
+
if (!run) {
|
|
140
|
+
if (!j.dispatchedAt) {
|
|
141
|
+
await ctx.checkpoint('S11');
|
|
142
|
+
j.dispatchedAt = ctx.now(); ctx.saveJournal(j);
|
|
143
|
+
const d = await gh(ctx, ['workflow', 'run', RELEASE_WORKFLOW, '--repo', ctx.repo,
|
|
144
|
+
'--raw-field', 'mode=code', '--raw-field', `candidate_sha=${j.sha}`, '--raw-field', `version=${j.version}`]);
|
|
145
|
+
if (d.status !== 0) { j.dispatchedAt = null; ctx.saveJournal(j); throw transient('S11', `dispatch: ${tail(d.stderr, 300)}`); }
|
|
146
|
+
}
|
|
147
|
+
run = await poll(ctx, { limitMs: 5 * MIN, intervalMs: 15_000, fn: () => findReleaseRun(ctx, j) });
|
|
148
|
+
// Never dispatch twice: a run that has not appeared yet is looked for again next run; after 30 min it needs a person.
|
|
149
|
+
if (!run && ctx.now() - j.dispatchedAt < 30 * MIN) throw transient('S11', `no "protected-release code ${j.version}" run yet`);
|
|
150
|
+
if (!run) throw new AutofixAbort('S11', 'dispatch-not-found', `no "protected-release code ${j.version}" run 30 minutes after the dispatch`);
|
|
151
|
+
}
|
|
152
|
+
j.releaseRunId = run.databaseId; ctx.saveJournal(j);
|
|
153
|
+
const w = await gh(ctx, ['run', 'watch', String(run.databaseId), '--repo', ctx.repo, '--exit-status'], { timeoutMs: 150 * MIN });
|
|
154
|
+
if (w.timedOut) throw transient('S11', 'protected-release still running after 150 minutes');
|
|
155
|
+
const v = await ghJson(ctx, ['run', 'view', String(run.databaseId), '--repo', ctx.repo, '--json', 'status,jobs,conclusion']);
|
|
156
|
+
if (!v.ok) throw transient('S11', `cannot read run ${run.databaseId}`);
|
|
157
|
+
if (v.value.status !== 'completed') throw transient('S11', `run ${run.databaseId} not finished`);
|
|
158
|
+
const jobs = Array.isArray(v.value.jobs) ? v.value.jobs : [];
|
|
159
|
+
const bad = jobs.filter((x) => !OK_JOB.has(x.conclusion)).map((x) => `${x.name}=${x.conclusion}`);
|
|
160
|
+
const fin = jobs.find((x) => x.name === 'finalize-public-verification');
|
|
161
|
+
if (!jobs.length || bad.length || !fin || fin.conclusion !== 'success') {
|
|
162
|
+
throw new AutofixAbort('S11', 'release-failed', `run ${run.databaseId}: ${bad.join(', ') || 'none red'}; finalize=${fin?.conclusion}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Once per release (journal-guarded): ask the recovery rail to re-run public verification on a published tag. */
|
|
167
|
+
async function recoverOnce(ctx, j) {
|
|
168
|
+
if (j.recoveryDispatched || !j.releaseRunId) return false;
|
|
169
|
+
const a = await ghJson(ctx, ['api', `repos/${ctx.repo}/actions/runs/${j.releaseRunId}/artifacts`]);
|
|
170
|
+
const art = (a.value?.artifacts || []).find((x) => x.name === `publication-evidence-${j.sha}` && x.expired === false);
|
|
171
|
+
if (!art) return false;
|
|
172
|
+
await ctx.checkpoint('S12');
|
|
173
|
+
const payload = { event_type: 'recover-public-verification',
|
|
174
|
+
client_payload: { candidate_sha: j.sha, version: j.version, publication_run_id: String(j.releaseRunId), artifact_id: String(art.id) } };
|
|
175
|
+
j.recoveryDispatched = true; ctx.saveJournal(j);
|
|
176
|
+
const r = await gh(ctx, ['api', `repos/${ctx.repo}/dispatches`, '--method', 'POST', '--input', '-'], { input: JSON.stringify(payload) });
|
|
177
|
+
return r.status === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drive the journal `j` ({version, branch, sha, issues, ...}) to a terminal phase:
|
|
182
|
+
* 'shipped' | 'published-unverified' (halt) | 'aborted' (red before main moved; nothing public changed) |
|
|
183
|
+
* 'halted' (red after main moved) | 'paused' (transient; resume next run).
|
|
184
|
+
*/
|
|
185
|
+
export async function shipRelease(ctx, { rwt, journal: j }) {
|
|
186
|
+
const res = () => ({ version: j.version, sha: j.sha, branch: j.branch, prNumber: j.prNumber || null,
|
|
187
|
+
preflightRunId: j.preflightRunId || null, releaseRunId: j.releaseRunId || null, terminal: j.terminal || null,
|
|
188
|
+
recoveryDispatched: !!j.recoveryDispatched });
|
|
189
|
+
let stage = 'S7';
|
|
190
|
+
try {
|
|
191
|
+
assertVersion('S7', j.version);
|
|
192
|
+
if (!SHA_RE.test(String(j.sha))) throw new AutofixAbort('S7', 'push-failed', `bad candidate sha ${j.sha}`);
|
|
193
|
+
if (!j.moved) {
|
|
194
|
+
stage = 'S7'; await stepPush(ctx, j, rwt);
|
|
195
|
+
stage = 'S8'; await stepPreflight(ctx, j);
|
|
196
|
+
stage = 'S9'; await stepPr(ctx, j);
|
|
197
|
+
await stepPreDispatch(ctx, j, rwt);
|
|
198
|
+
stage = 'S10'; await stepPromote(ctx, j, rwt);
|
|
199
|
+
}
|
|
200
|
+
stage = 'S11'; await stepDispatch(ctx, j);
|
|
201
|
+
stage = 'S12';
|
|
202
|
+
j.terminal = await verifyTerminal(ctx, { version: j.version, sha: j.sha }); ctx.saveJournal(j);
|
|
203
|
+
if (!j.terminal.installVerified) throw new AutofixAbort('S12', 'terminal-unverified', `npm ${j.terminal.npmLatest}, release ${j.terminal.releasesLatest}, main ${j.terminal.mainSha}`);
|
|
204
|
+
j.phase = 'shipped'; ctx.saveJournal(j);
|
|
205
|
+
return { ...res(), phase: 'shipped', abort: null };
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const a = e instanceof AutofixAbort ? e : transient(stage, e?.message || String(e));
|
|
208
|
+
const abort = { stage: a.stage || stage, code: a.code, detail: a.detail, halt: false };
|
|
209
|
+
if (!j.moved) {
|
|
210
|
+
if (TRANSIENT.has(a.code)) { j.phase = 'paused'; ctx.saveJournal(j); return { ...res(), phase: 'paused', abort }; }
|
|
211
|
+
if (j.prNumber) await gh(ctx, ['pr', 'close', String(j.prNumber), '--repo', ctx.repo]); // best effort; the branch is kept
|
|
212
|
+
j.phase = 'aborted'; ctx.saveJournal(j);
|
|
213
|
+
return { ...res(), phase: 'aborted', abort };
|
|
214
|
+
}
|
|
215
|
+
if (TRANSIENT.has(a.code)) { j.phase = 'paused'; ctx.saveJournal(j); return { ...res(), phase: 'paused', abort }; }
|
|
216
|
+
abort.halt = true;
|
|
217
|
+
j.terminal = j.terminal || await verifyTerminal(ctx, { version: j.version, sha: j.sha }).catch(() => null);
|
|
218
|
+
if (j.terminal?.npmLatest === j.version) {
|
|
219
|
+
try { await recoverOnce(ctx, j); } catch { /* the recovery rail is best effort; the halt page carries the state */ }
|
|
220
|
+
j.phase = 'published-unverified'; ctx.saveJournal(j);
|
|
221
|
+
return { ...res(), phase: 'published-unverified', abort };
|
|
222
|
+
}
|
|
223
|
+
j.phase = 'halted'; ctx.saveJournal(j);
|
|
224
|
+
return { ...res(), phase: 'halted', abort };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Shared helpers for the autofix release path (ADR-107, batch A3). No child_process here: every
|
|
2
|
+
// external command goes through the injected `ctx.exec` (see FROZEN INTERFACES).
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AutofixAbort, findClosingKeywords } from './issue-autofix-policy.mjs';
|
|
6
|
+
|
|
7
|
+
export const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
8
|
+
export const SHA_RE = /^[0-9a-f]{40}$/;
|
|
9
|
+
export const MIN = 60_000;
|
|
10
|
+
export const tail = (text, n = 1500) => String(text ?? '').slice(-n);
|
|
11
|
+
export const ghBin = (ctx) => ctx.ghBin || 'gh';
|
|
12
|
+
export const gitBin = (ctx) => ctx.gitBin || 'git';
|
|
13
|
+
|
|
14
|
+
export async function run(ctx, cmd, args, opts = {}) {
|
|
15
|
+
return ctx.exec(cmd, args, opts);
|
|
16
|
+
}
|
|
17
|
+
export const describe = (cmd, args, r) =>
|
|
18
|
+
`${cmd} ${args.join(' ')} -> ${r.timedOut ? 'timed out' : r.status}: ${tail(r.stderr || r.stdout, 400)}`;
|
|
19
|
+
|
|
20
|
+
/** Run and throw AutofixAbort(stage, code) on any non-zero exit or timeout. */
|
|
21
|
+
export async function must(ctx, stage, code, cmd, args, opts = {}) {
|
|
22
|
+
const r = await run(ctx, cmd, args, opts);
|
|
23
|
+
if (r.status !== 0 || r.timedOut) throw new AutofixAbort(stage, code, describe(cmd, args, r));
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
export const git = (ctx, dir, args, opts) => run(ctx, gitBin(ctx), ['-C', dir, ...args], opts);
|
|
27
|
+
export const mustGit = (ctx, stage, code, dir, args, opts) => must(ctx, stage, code, gitBin(ctx), ['-C', dir, ...args], opts);
|
|
28
|
+
export const gh = (ctx, args, opts) => run(ctx, ghBin(ctx), args, opts);
|
|
29
|
+
|
|
30
|
+
export async function ghJson(ctx, args, opts) {
|
|
31
|
+
const r = await gh(ctx, args, opts);
|
|
32
|
+
if (r.status !== 0 || r.timedOut) return { ok: false, value: null, result: r };
|
|
33
|
+
try { return { ok: true, value: JSON.parse(r.stdout || 'null'), result: r }; }
|
|
34
|
+
catch { return { ok: false, value: null, result: r }; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Per-command identity (no repo or global git config is ever written). The env copy stops an ambient GIT_AUTHOR_* from winning. */
|
|
38
|
+
export const identityArgs = (ctx) => ['-c', `user.name=${ctx.identity.name}`, '-c', `user.email=${ctx.identity.email}`,
|
|
39
|
+
'-c', 'commit.gpgsign=false'];
|
|
40
|
+
export const identityEnv = (ctx) => ({ ...process.env, GIT_AUTHOR_NAME: ctx.identity.name, GIT_AUTHOR_EMAIL: ctx.identity.email,
|
|
41
|
+
GIT_COMMITTER_NAME: ctx.identity.name, GIT_COMMITTER_EMAIL: ctx.identity.email });
|
|
42
|
+
/** git commit / cherry-pick as the autofix identity. */
|
|
43
|
+
export const gitAs = (ctx, dir, args) => git(ctx, dir, [...identityArgs(ctx), ...args], { env: identityEnv(ctx) });
|
|
44
|
+
export const mustGitAs = async (ctx, stage, code, dir, args) => {
|
|
45
|
+
const r = await gitAs(ctx, dir, args);
|
|
46
|
+
if (r.status !== 0 || r.timedOut) throw new AutofixAbort(stage, code, describe(gitBin(ctx), args, r));
|
|
47
|
+
return r;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Throws when text would auto-close an issue (closing keyword + #N) or credit a co-author. */
|
|
51
|
+
export function assertCleanText(stage, text) {
|
|
52
|
+
const hits = findClosingKeywords(String(text));
|
|
53
|
+
if (hits.length) throw new AutofixAbort(stage, 'forbidden-command', `closing keyword in outgoing text: ${hits.join(', ')}`);
|
|
54
|
+
if (/co-authored-by/i.test(String(text))) throw new AutofixAbort(stage, 'forbidden-command', 'co-author trailer in outgoing text');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One printable line, no '#', no newlines, bounded. */
|
|
58
|
+
export function sanitizeTitle(title, max = 72) {
|
|
59
|
+
return String(title ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/#/g, '').replace(/\s+/g, ' ').trim().slice(0, max).trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertVersion(stage, version) {
|
|
63
|
+
if (!VERSION_RE.test(String(version))) throw new AutofixAbort(stage, 'branch-exists', `not a plain X.Y.Z version: ${version}`);
|
|
64
|
+
}
|
|
65
|
+
export function assertIssue(stage, n) {
|
|
66
|
+
if (!Number.isInteger(n) || n <= 0) throw new AutofixAbort(stage, 'diff-policy', `bad issue number: ${n}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const worktreesDir = (ctx) => ctx.worktrees || path.join(path.dirname(ctx.clone), 'issue-fix-worktrees');
|
|
70
|
+
export const holdFilePath = (ctx) => ctx.holdFile
|
|
71
|
+
|| path.join(process.env.HOME || os.homedir(), '.config', 'ruvnet-brain', 'release-hold');
|
|
72
|
+
|
|
73
|
+
/** Next 05:17 UTC strictly after `ms` (the corpus nightly dispatch). */
|
|
74
|
+
export function nextCorpusDispatch(ms) {
|
|
75
|
+
const d = new Date(ms);
|
|
76
|
+
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 5, 17, 0);
|
|
77
|
+
if (t <= ms) t += 24 * 3_600_000;
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Poll `fn` until truthy or the limit passes. Bounded by attempts too, so a frozen fake clock cannot hang. */
|
|
82
|
+
export async function poll(ctx, { limitMs, intervalMs, fn }) {
|
|
83
|
+
const end = ctx.now() + limitMs;
|
|
84
|
+
const attempts = Math.ceil(limitMs / intervalMs) + 2;
|
|
85
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
86
|
+
const value = await fn();
|
|
87
|
+
if (value) return value;
|
|
88
|
+
if (ctx.now() >= end) break;
|
|
89
|
+
await ctx.sleep(intervalMs);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse `git diff -U0` output into the DiffFile[] shape judgeDiff/judgeGovernedVersionOnly consume. */
|
|
95
|
+
export function parseUnifiedDiff(text) {
|
|
96
|
+
const files = [];
|
|
97
|
+
let cur = null;
|
|
98
|
+
let inHunk = false;
|
|
99
|
+
for (const line of String(text).split('\n')) {
|
|
100
|
+
if (line.startsWith('diff --git ')) {
|
|
101
|
+
const m = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
102
|
+
cur = { path: m ? m[2] : '', status: 'M', added: [], removed: [], binary: false, symlink: false };
|
|
103
|
+
files.push(cur); inHunk = false; continue;
|
|
104
|
+
}
|
|
105
|
+
if (!cur) continue;
|
|
106
|
+
if (line.startsWith('@@')) { inHunk = true; continue; }
|
|
107
|
+
if (!inHunk) {
|
|
108
|
+
if (line.startsWith('new file mode')) cur.status = 'A';
|
|
109
|
+
else if (line.startsWith('deleted file mode')) cur.status = 'D';
|
|
110
|
+
else if (line.startsWith('rename to ')) { cur.status = 'R'; cur.path = line.slice('rename to '.length); }
|
|
111
|
+
if (/^(new file mode|deleted file mode|new mode|old mode) 120000$/.test(line) || /^index [0-9a-f.]+ 120000$/.test(line)) cur.symlink = true;
|
|
112
|
+
if (line.startsWith('Binary files') || line === 'GIT binary patch') cur.binary = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (line.startsWith('+')) cur.added.push(line.slice(1));
|
|
116
|
+
else if (line.startsWith('-')) cur.removed.push(line.slice(1));
|
|
117
|
+
}
|
|
118
|
+
return files;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- shared remote-state queries (window facts, terminal check) ----
|
|
122
|
+
|
|
123
|
+
export async function queryNpmLatest(ctx) {
|
|
124
|
+
const r = await run(ctx, 'npm', ['view', 'ruvnet-brain', 'dist-tags.latest']);
|
|
125
|
+
const v = String(r.stdout || '').trim();
|
|
126
|
+
return r.status === 0 && VERSION_RE.test(v) ? v : null;
|
|
127
|
+
}
|
|
128
|
+
export async function queryReleasesLatest(ctx) {
|
|
129
|
+
const j = await ghJson(ctx, ['release', 'view', '--repo', ctx.repo, '--json', 'tagName']);
|
|
130
|
+
return j.ok && j.value && typeof j.value.tagName === 'string' ? j.value.tagName : null;
|
|
131
|
+
}
|
|
132
|
+
export async function queryRemoteMain(ctx, dir = ctx.clone) {
|
|
133
|
+
const r = await git(ctx, dir, ['ls-remote', 'origin', 'refs/heads/main']);
|
|
134
|
+
const sha = String(r.stdout || '').trim().split(/\s+/)[0];
|
|
135
|
+
return r.status === 0 && SHA_RE.test(sha) ? sha : null;
|
|
136
|
+
}
|
|
137
|
+
export async function resolveRuntime(ctx, version, outFile) {
|
|
138
|
+
const r = await run(ctx, 'node', ['scripts/approved-runtime.mjs', '--resolve', '--repo', ctx.repo, '--tag', `v${version}`, '--out', outFile],
|
|
139
|
+
{ cwd: ctx.clone });
|
|
140
|
+
return r.status === 0 && !r.timedOut;
|
|
141
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Release path for the unattended issue autofix (ADR-107, batch A3): S6 release branch, census stamp,
|
|
2
|
+
// the mechanical ADR-0103 review row, local gates, and (in issue-autofix-release-ship.mjs) S7-S12.
|
|
3
|
+
// Follows CONTRIBUTING.md "Releasing code (step by step)". Every external command goes through ctx.exec.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { AutofixAbort, nextPatchVersion, judgeGovernedVersionOnly } from './issue-autofix-policy.mjs';
|
|
8
|
+
import { evaluateDoc } from '../plugin/scripts/doc-currency.mjs';
|
|
9
|
+
import { GOVERNING_ADR, evaluateArchitectureReview } from './architecture-review-lock.mjs';
|
|
10
|
+
import {
|
|
11
|
+
MIN, VERSION_RE, SHA_RE, tail, run, must, git, mustGit, mustGitAs, gitAs, ghJson, ghBin, assertCleanText,
|
|
12
|
+
assertVersion, assertIssue, worktreesDir, holdFilePath, nextCorpusDispatch, parseUnifiedDiff,
|
|
13
|
+
queryNpmLatest, queryReleasesLatest, resolveRuntime,
|
|
14
|
+
} from './issue-autofix-release-util.mjs';
|
|
15
|
+
|
|
16
|
+
export { shipRelease, verifyTerminal } from './issue-autofix-release-ship.mjs';
|
|
17
|
+
export { parseUnifiedDiff } from './issue-autofix-release-util.mjs';
|
|
18
|
+
|
|
19
|
+
// release:prepush (CONTRIBUTING "before the first push", 4.7.23) runs first: it catches a stale census against the
|
|
20
|
+
// newest corpus generation, a stale ADR-0103 digest, stray artifacts and anchored text tests in seconds.
|
|
21
|
+
export const LOCAL_GATES = Object.freeze([
|
|
22
|
+
['release-prepush', 'npm', ['run', 'release:prepush', '--', '--receipt', '<RUN>/prepush-<TS>.json']],
|
|
23
|
+
['release-qualify-source', 'npm', ['run', 'release:qualify', '--', '--suite', 'source', '--report', '<RUN>/source-<TS>.json']],
|
|
24
|
+
['release-qualify-integration', 'npm', ['run', 'release:qualify', '--', '--suite', 'integration', '--report', '<RUN>/integration-<TS>.json']],
|
|
25
|
+
['npm-test', 'npm', ['test']],
|
|
26
|
+
['single-source', 'npm', ['run', 'single-source:check']],
|
|
27
|
+
['release-syntax', 'node', ['--check', 'scripts/release.mjs']],
|
|
28
|
+
['version-check', 'npm', ['run', 'version:check']],
|
|
29
|
+
['release-authority', 'npm', ['run', 'release:authority']],
|
|
30
|
+
['architecture-review-lock', 'node', ['scripts/architecture-review-lock.mjs']],
|
|
31
|
+
['doc-currency', 'npm', ['run', 'doc:currency']],
|
|
32
|
+
]);
|
|
33
|
+
const GATED_WORKFLOWS = ['protected-release.yml', 'release-candidate-preflight.yml', 'corpus-nightly-dispatch.yml'];
|
|
34
|
+
const GATE_TIMEOUT_MS = { 'release-qualify-integration': 120 * MIN, 'release-qualify-source': 60 * MIN, 'npm-test': 60 * MIN };
|
|
35
|
+
const MANIFEST = 'plugin/.claude-plugin/plugin.json';
|
|
36
|
+
|
|
37
|
+
async function mainVersion(ctx, stage) {
|
|
38
|
+
const r = await mustGit(ctx, stage, 'main-moved', ctx.clone, ['show', `origin/main:${MANIFEST}`]);
|
|
39
|
+
try { return JSON.parse(r.stdout).version; } catch { throw new AutofixAbort(stage, 'main-moved', 'origin/main manifest unreadable'); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function gatherWindowFacts(ctx) {
|
|
43
|
+
const facts = { openReleaseBlockers: [], activeRuns: [], remoteReleaseBranches: [], openReleasePrs: [], holdFile: false,
|
|
44
|
+
mainVersion: '', mainSha: '', npmLatest: null, releasesLatestTag: null, mainVersionInstallVerified: false,
|
|
45
|
+
nextCorpusDispatchAt: nextCorpusDispatch(ctx.now()) };
|
|
46
|
+
const issues = await ghJson(ctx, ['issue', 'list', '--repo', ctx.repo, '--state', 'open', '--label', 'release-blocker', '--json', 'number', '--limit', '100']);
|
|
47
|
+
if (!issues.ok) throw new AutofixAbort('S0', 'window-closed', 'cannot list release-blocker issues');
|
|
48
|
+
facts.openReleaseBlockers = issues.value.map((i) => i.number);
|
|
49
|
+
for (const wf of GATED_WORKFLOWS) {
|
|
50
|
+
for (const status of ['queued', 'in_progress']) {
|
|
51
|
+
const j = await ghJson(ctx, ['run', 'list', '--repo', ctx.repo, '--workflow', wf, '--status', status, '--json', 'databaseId,status', '--limit', '20']);
|
|
52
|
+
if (!j.ok) throw new AutofixAbort('S0', 'window-closed', `cannot list ${status} runs of ${wf}`);
|
|
53
|
+
for (const x of j.value) facts.activeRuns.push({ workflow: wf, status: x.status || status, id: x.databaseId });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await mustGit(ctx, 'S0', 'window-closed', ctx.clone, ['fetch', 'origin', 'main']);
|
|
57
|
+
const heads = await mustGit(ctx, 'S0', 'window-closed', ctx.clone, ['ls-remote', '--heads', 'origin', 'refs/heads/release/*']);
|
|
58
|
+
for (const line of heads.stdout.split('\n').filter(Boolean)) {
|
|
59
|
+
const name = line.split(/\s+/)[1]?.replace(/^refs\/heads\//, '') || '';
|
|
60
|
+
// release/4.5.15-stop is a 4.5.15 work branch: its leading version decides whether it is ahead of main.
|
|
61
|
+
const m = name.replace(/^release\//, '').match(/^(\d+\.\d+\.\d+)(?:[-_.+][\w.-]*)?$/);
|
|
62
|
+
facts.remoteReleaseBranches.push({ name, version: m ? m[1] : '' });
|
|
63
|
+
}
|
|
64
|
+
const prs = await ghJson(ctx, ['pr', 'list', '--repo', ctx.repo, '--state', 'open', '--json', 'number,headRefName', '--limit', '100']);
|
|
65
|
+
if (!prs.ok) throw new AutofixAbort('S0', 'window-closed', 'cannot list open pull requests');
|
|
66
|
+
const releasePrs = prs.value.filter((p) => String(p.headRefName).startsWith('release/'));
|
|
67
|
+
facts.openReleasePrs = releasePrs.map((p) => p.number);
|
|
68
|
+
facts.openReleasePrHeads = Object.fromEntries(releasePrs.map((p) => [p.number, p.headRefName]));
|
|
69
|
+
facts.holdFile = fs.existsSync(holdFilePath(ctx));
|
|
70
|
+
facts.mainVersion = await mainVersion(ctx, 'S0');
|
|
71
|
+
facts.mainSha = (await mustGit(ctx, 'S0', 'window-closed', ctx.clone, ['rev-parse', 'origin/main'])).stdout.trim();
|
|
72
|
+
facts.npmLatest = await queryNpmLatest(ctx);
|
|
73
|
+
facts.releasesLatestTag = await queryReleasesLatest(ctx);
|
|
74
|
+
facts.mainVersionInstallVerified = await resolveRuntime(ctx, facts.mainVersion, path.join(ctx.runDir || '.', `window-runtime-${facts.mainVersion}.json`));
|
|
75
|
+
return facts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The message names only the issue number: a stranger's title never enters git history (no @mentions, links or
|
|
79
|
+
// injection text in commits that ship), and no closing keyword can auto-close anything.
|
|
80
|
+
export async function commitFix(ctx, { wt, issueNumber }) {
|
|
81
|
+
assertIssue('S6', issueNumber);
|
|
82
|
+
const subject = `autofix: issue ${issueNumber}`;
|
|
83
|
+
const body = `Refs issue ${issueNumber} (ADR-107 autofix).`;
|
|
84
|
+
assertCleanText('S6', `${subject}\n${body}`);
|
|
85
|
+
await mustGit(ctx, 'S6', 'diff-policy', wt, ['add', '-A']);
|
|
86
|
+
const staged = await git(ctx, wt, ['diff', '--cached', '--quiet']);
|
|
87
|
+
if (staged.status === 0) throw new AutofixAbort('S6', 'diff-policy', 'nothing to commit in the fix worktree');
|
|
88
|
+
await mustGitAs(ctx, 'S6', 'diff-policy', wt, ['commit', '-m', subject, '-m', body]);
|
|
89
|
+
const fixSha = (await mustGit(ctx, 'S6', 'diff-policy', wt, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
90
|
+
return { fixSha };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* S6: one release branch for the night. Version commit first, then every ready fix cherry-picked in issue order; a fix
|
|
95
|
+
* that does not apply cleanly on top of the others is dropped (and reported), never hand-merged. Returns the version
|
|
96
|
+
* commit (fixBase) and the last fix commit (fixHead) so the batch gate can scan exactly the model-written code.
|
|
97
|
+
*/
|
|
98
|
+
export async function prepareReleaseBranch(ctx, { version, fixes }) {
|
|
99
|
+
assertVersion('S6', version);
|
|
100
|
+
if (!Array.isArray(fixes) || !fixes.length) throw new AutofixAbort('S6', 'diff-policy', 'no fixes to release');
|
|
101
|
+
for (const f of fixes) {
|
|
102
|
+
assertIssue('S6', f.issueNumber);
|
|
103
|
+
if (!SHA_RE.test(String(f.fixSha))) throw new AutofixAbort('S6', 'diff-policy', `not a commit sha: ${f.fixSha}`);
|
|
104
|
+
}
|
|
105
|
+
const branch = `release/${version}`;
|
|
106
|
+
await mustGit(ctx, 'S6', 'main-moved', ctx.clone, ['fetch', 'origin', 'main']);
|
|
107
|
+
const current = await mainVersion(ctx, 'S6');
|
|
108
|
+
if (nextPatchVersion(current) !== version) {
|
|
109
|
+
throw new AutofixAbort('S6', 'main-moved', `origin/main is ${current}; the next patch is ${nextPatchVersion(current)}, not ${version}`);
|
|
110
|
+
}
|
|
111
|
+
const remote = await git(ctx, ctx.clone, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`]);
|
|
112
|
+
if (remote.status !== 0) throw new AutofixAbort('S6', 'branch-exists', `cannot tell whether ${branch} exists on the remote`);
|
|
113
|
+
if (remote.stdout.trim()) throw new AutofixAbort('S6', 'branch-exists', `${branch} already exists on the remote`);
|
|
114
|
+
const local = await git(ctx, ctx.clone, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
115
|
+
if (local.status === 0) throw new AutofixAbort('S6', 'branch-exists', `${branch} already exists locally`);
|
|
116
|
+
|
|
117
|
+
const rwt = path.join(worktreesDir(ctx), `release-${version}`);
|
|
118
|
+
fs.mkdirSync(path.dirname(rwt), { recursive: true });
|
|
119
|
+
await mustGit(ctx, 'S6', 'branch-exists', ctx.clone, ['worktree', 'add', '-b', branch, rwt, 'origin/main']);
|
|
120
|
+
await must(ctx, 'S6', 'gate-failed', 'npm', ['ci', '--no-audit', '--no-fund'], { cwd: rwt });
|
|
121
|
+
await must(ctx, 'S6', 'gate-failed', 'npm', ['run', 'version:set', '--', version], { cwd: rwt });
|
|
122
|
+
await must(ctx, 'S6', 'gate-failed', 'npm', ['run', 'convergence:write'], { cwd: rwt });
|
|
123
|
+
const issues = fixes.map((f) => f.issueNumber);
|
|
124
|
+
const msg = `chore(release): ${version} (autofix, issues ${issues.join(', ')})`;
|
|
125
|
+
assertCleanText('S6', msg);
|
|
126
|
+
await mustGit(ctx, 'S6', 'gate-failed', rwt, ['add', '-A']);
|
|
127
|
+
await mustGitAs(ctx, 'S6', 'gate-failed', rwt, ['commit', '-m', msg]);
|
|
128
|
+
const fixBase = (await mustGit(ctx, 'S6', 'gate-failed', rwt, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
129
|
+
const applied = []; const dropped = [];
|
|
130
|
+
for (const f of fixes) {
|
|
131
|
+
const pick = await gitAs(ctx, rwt, ['cherry-pick', f.fixSha]);
|
|
132
|
+
if (pick.status === 0) { applied.push(f.issueNumber); continue; }
|
|
133
|
+
await git(ctx, rwt, ['cherry-pick', '--abort']);
|
|
134
|
+
dropped.push({ issueNumber: f.issueNumber, reason: `does not apply on top of the other fixes: ${tail(pick.stderr, 200)}` });
|
|
135
|
+
}
|
|
136
|
+
if (!applied.length) throw new AutofixAbort('S6', 'gate-failed', 'no fix applied cleanly to the release branch');
|
|
137
|
+
const fixHead = (await mustGit(ctx, 'S6', 'gate-failed', rwt, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
138
|
+
const log = await mustGit(ctx, 'S6', 'gate-failed', rwt, ['rev-list', '--reverse', 'origin/main..HEAD']);
|
|
139
|
+
return { branch, rwt, fixBase, fixHead, applied, dropped, commits: log.stdout.split('\n').filter(Boolean) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
143
|
+
function findAssetDirs(root) {
|
|
144
|
+
const dirs = new Set();
|
|
145
|
+
const walk = (d) => {
|
|
146
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
147
|
+
const p = path.join(d, e.name);
|
|
148
|
+
if (e.isDirectory()) { if (e.name !== '__MACOSX') walk(p); }
|
|
149
|
+
else if (e.name.endsWith('.big.rvf') && !e.name.startsWith('._')) dirs.add(d);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(root);
|
|
153
|
+
return [...dirs];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Census stamp, assembled exactly as ci.yml release-qe does (CONTRIBUTING rule 1). */
|
|
157
|
+
export async function stampCensus(ctx, { rwt }) {
|
|
158
|
+
const work = path.join(ctx.runDir || path.dirname(rwt), `census-${ctx.now()}`);
|
|
159
|
+
const evidence = path.join(work, 'release-evidence');
|
|
160
|
+
const seedDir = path.join(work, 'release-seed');
|
|
161
|
+
const extracted = path.join(seedDir, 'extracted');
|
|
162
|
+
fs.mkdirSync(evidence, { recursive: true });
|
|
163
|
+
fs.mkdirSync(extracted, { recursive: true });
|
|
164
|
+
const descriptor = path.join(evidence, 'corpus-seed.json');
|
|
165
|
+
const opts = { cwd: rwt, timeoutMs: 30 * MIN };
|
|
166
|
+
await must(ctx, 'S6', 'gate-failed', 'node', ['scripts/corpus-next-seed.mjs', '--repo', ctx.repo, '--require-coverage', '--out', descriptor], opts);
|
|
167
|
+
const d = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
|
|
168
|
+
const patterns = ['--pattern', d.asset];
|
|
169
|
+
const cov = d.origin === 'published-generation';
|
|
170
|
+
if (cov) patterns.push('--pattern', 'CORPUS-COVERAGE.json', '--pattern', 'coverage-receipt.json', '--pattern', `${d.asset}.recall.json`);
|
|
171
|
+
await must(ctx, 'S6', 'gate-failed', ghBin(ctx), ['release', 'download', d.tag, '--repo', ctx.repo, ...patterns, '--dir', seedDir], opts);
|
|
172
|
+
const seed = path.join(seedDir, d.asset);
|
|
173
|
+
if (sha256File(seed) !== d.sha256 || fs.statSync(seed).size !== Number(d.bytes)) {
|
|
174
|
+
throw new AutofixAbort('S6', 'gate-failed', 'the downloaded corpus seed does not match its descriptor');
|
|
175
|
+
}
|
|
176
|
+
await must(ctx, 'S6', 'gate-failed', 'unzip', ['-q', seed, '-d', extracted], opts);
|
|
177
|
+
const assetDirs = findAssetDirs(extracted);
|
|
178
|
+
if (assetDirs.length !== 1) throw new AutofixAbort('S6', 'gate-failed', `expected one asset directory in the seed, found ${assetDirs.length}`);
|
|
179
|
+
const covArgs = cov ? ['--coverage', path.join(seedDir, 'CORPUS-COVERAGE.json'), '--coverage-receipt', path.join(seedDir, 'coverage-receipt.json')] : [];
|
|
180
|
+
await must(ctx, 'S6', 'gate-failed', 'node', ['scripts/code-release-corpus.mjs', 'assemble', '--descriptor', descriptor, '--seed-bundle', seed,
|
|
181
|
+
'--assets', assetDirs[0], '--evidence-dir', evidence, ...covArgs], opts);
|
|
182
|
+
await must(ctx, 'S6', 'gate-failed', 'node', ['scripts/sync-census.mjs'],
|
|
183
|
+
{ ...opts, env: { ...process.env, RUVNET_BRAIN_KB: path.join(rwt, 'dist', 'ruvnet-brain') } });
|
|
184
|
+
const changed = (await mustGit(ctx, 'S6', 'gate-failed', rwt, ['diff', '--name-only'])).stdout.split('\n').filter(Boolean);
|
|
185
|
+
if (!changed.length) return { changed: false, files: [] };
|
|
186
|
+
const version = JSON.parse(fs.readFileSync(path.join(rwt, MANIFEST), 'utf8')).version;
|
|
187
|
+
await mustGit(ctx, 'S6', 'gate-failed', rwt, ['add', '--', ...changed]);
|
|
188
|
+
await mustGitAs(ctx, 'S6', 'gate-failed', rwt, ['commit', '-m', `chore(release): stamp the census for ${version} (autofix)`]);
|
|
189
|
+
return { changed: true, files: changed };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeReview(rwt, { digest, version, issues, date, contractTouched, anchor }) {
|
|
193
|
+
const file = path.join(rwt, GOVERNING_ADR);
|
|
194
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
195
|
+
const fmEnd = lines.indexOf('---', 1);
|
|
196
|
+
const fi = lines.findIndex((l, i) => i > 0 && i < fmEnd && l.startsWith('reviewed_digest:'));
|
|
197
|
+
if (fi >= 0) lines[fi] = `reviewed_digest: ${digest}`;
|
|
198
|
+
else lines.splice(fmEnd, 0, `reviewed_digest: ${digest}`);
|
|
199
|
+
const ui = lines.findIndex((l, i) => i > 0 && i < fmEnd + 1 && l.startsWith('updated:'));
|
|
200
|
+
if (ui >= 0) lines[ui] = `updated: ${date}`;
|
|
201
|
+
const extra = contractTouched ? ' and append-only inventory lines in `scripts/release-qualification-contract.mjs`' : '';
|
|
202
|
+
const row = `| ${date} | Review \`${digest}\` (${version}, autofix issues ${issues.join(', ')}, mechanical): the only governed bytes that moved since origin/main are the version fields of \`package.json\` and \`package-lock.json\`${extra}, proven by a diff of origin/main against this branch before the row was written; no routing behavior is added. | Written by scripts/issue-autofix-release.mjs under ADR-107 (the digest covers the governed path \`${anchor}\`); a mechanical stamp, not a person reading the code. |`;
|
|
203
|
+
const h = lines.findIndex((l) => /^##\s+currency log\b/i.test(l));
|
|
204
|
+
if (h < 0) throw new AutofixAbort('S6', 'governed-change-not-mechanical', 'ADR-0103 has no Currency log section');
|
|
205
|
+
let at = lines.findIndex((l, i) => i > h && /^\|\s*\d{4}-\d{2}-\d{2}\s*\|/.test(l));
|
|
206
|
+
if (at < 0) at = h + 1;
|
|
207
|
+
lines.splice(at, 0, row);
|
|
208
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Mechanical ADR-0103 review row; refuses (governed-change-not-mechanical) unless the governed diff is version lines only. */
|
|
212
|
+
export async function stamp0103Review(ctx, { rwt, version, issues, date }) {
|
|
213
|
+
if (!Array.isArray(issues) || !issues.length) throw new AutofixAbort('S6', 'governed-change-not-mechanical', 'no issues named');
|
|
214
|
+
for (const n of issues) assertIssue('S6', n);
|
|
215
|
+
const evaluate = ctx.evaluateDoc || evaluateDoc;
|
|
216
|
+
const doc = evaluate(rwt, GOVERNING_ADR, { checkWiring: false });
|
|
217
|
+
const governed = new Set((doc.governed || []).filter((g) => g.resolved).map((g) => g.path));
|
|
218
|
+
const diff = await mustGit(ctx, 'S6', 'governed-change-not-mechanical', rwt, ['diff', '-U0', '--no-color', '--no-ext-diff', 'origin/main', 'HEAD']);
|
|
219
|
+
const touched = parseUnifiedDiff(diff.stdout).filter((f) => governed.has(f.path));
|
|
220
|
+
const verdict = judgeGovernedVersionOnly(touched);
|
|
221
|
+
if (!verdict.mechanical) {
|
|
222
|
+
throw new AutofixAbort('S6', 'governed-change-not-mechanical', verdict.reason || 'governed bytes changed beyond version lines');
|
|
223
|
+
}
|
|
224
|
+
if (doc.review?.current === true) return { digest: doc.review.computed, rowAdded: false };
|
|
225
|
+
const digest = doc.review?.computed;
|
|
226
|
+
if (!digest) throw new AutofixAbort('S6', 'governed-change-not-mechanical', 'no review digest can be computed for ADR-0103');
|
|
227
|
+
// doc-currency only counts a path referent that has a directory part, so anchor the row on a governed path that does.
|
|
228
|
+
const anchor = ['scripts/release-qualification-contract.mjs', ...governed].find((p) => p.includes('/') && governed.has(p));
|
|
229
|
+
if (!anchor) throw new AutofixAbort('S6', 'gate-failed', 'ADR-0103 governs no path with a directory part to anchor the review row on');
|
|
230
|
+
writeReview(rwt, { digest, version, issues, date, anchor, contractTouched: touched.some((f) => f.path === 'scripts/release-qualification-contract.mjs') });
|
|
231
|
+
const after = (ctx.evaluateReview || evaluateArchitectureReview)(rwt);
|
|
232
|
+
if (!after.pass) throw new AutofixAbort('S6', 'gate-failed', `ADR-0103 review is still not current after the row: ${JSON.stringify(after.findings).slice(0, 1500)}`);
|
|
233
|
+
await mustGit(ctx, 'S6', 'gate-failed', rwt, ['add', '--', GOVERNING_ADR]);
|
|
234
|
+
await mustGitAs(ctx, 'S6', 'gate-failed', rwt, ['commit', '-m', `chore(release): ADR-0103 mechanical review row for ${version}`]);
|
|
235
|
+
return { digest, rowAdded: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const blocksOf = (stdout) => {
|
|
239
|
+
try { return new Set(JSON.parse(stdout).docs.flatMap((d) => (d.findings || []).filter((f) => f.level === 'block').map((f) => `${d.file}:${f.code}`))); }
|
|
240
|
+
catch { return null; }
|
|
241
|
+
};
|
|
242
|
+
/** doc:currency passes when the candidate adds no BLOCK that origin/main did not already have. */
|
|
243
|
+
async function noNewDocBlocks(ctx, rwt) {
|
|
244
|
+
const mine = blocksOf((await run(ctx, 'node', ['scripts/doc-currency.mjs', '--json'], { cwd: rwt })).stdout);
|
|
245
|
+
const basePath = `${rwt}-base`;
|
|
246
|
+
await mustGit(ctx, 'S6', 'gate-failed', ctx.clone, ['worktree', 'add', '--detach', basePath, 'origin/main']);
|
|
247
|
+
const base = blocksOf((await run(ctx, 'node', ['scripts/doc-currency.mjs', '--json'], { cwd: basePath })).stdout);
|
|
248
|
+
if (!mine || !base) return false;
|
|
249
|
+
return [...mine].every((k) => base.has(k));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function runLocalGates(ctx, { rwt }) {
|
|
253
|
+
const ts = String(ctx.now());
|
|
254
|
+
const gates = [];
|
|
255
|
+
for (const [name, cmd, tmpl] of LOCAL_GATES) {
|
|
256
|
+
await ctx.checkpoint(`gate:${name}`);
|
|
257
|
+
const args = tmpl.map((a) => a.replace('<RUN>', ctx.runDir || rwt).replace('<TS>', ts));
|
|
258
|
+
const started = ctx.now();
|
|
259
|
+
let exitCode; let output;
|
|
260
|
+
if (ctx.gates?.[name]) {
|
|
261
|
+
({ exitCode, output } = await ctx.gates[name](rwt));
|
|
262
|
+
} else {
|
|
263
|
+
const r = await run(ctx, cmd, args, { cwd: rwt, timeoutMs: GATE_TIMEOUT_MS[name] || 30 * MIN });
|
|
264
|
+
exitCode = r.timedOut ? 124 : r.status;
|
|
265
|
+
output = `${r.stdout || ''}${r.stderr || ''}`;
|
|
266
|
+
if (exitCode !== 0 && name === 'doc-currency' && await noNewDocBlocks(ctx, rwt)) exitCode = 0;
|
|
267
|
+
}
|
|
268
|
+
gates.push({ name, exitCode, durationMs: ctx.now() - started, outputTail: tail(output) });
|
|
269
|
+
if (exitCode !== 0) return { ok: false, gates };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, gates };
|
|
272
|
+
}
|