ruvnet-brain 4.0.7 → 4.0.8
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 +1 -1
- package/bin/install.mjs +6 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/publication-receipt.mjs +126 -34
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +233 -56
- package/scripts/release-transaction.mjs +295 -105
- package/scripts/release.mjs +41 -12
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/staged-host-verifier.mjs +3 -6
|
@@ -4,47 +4,75 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
export const RECEIPT_PREFIX = 'release-transaction-';
|
|
5
5
|
export const TERMINAL_STATES = new Set(['channels-converged', 'aborted']);
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
8
|
+
'remote-prepared': ['asset-upload-intent', 'manual-intervention-required'],
|
|
9
|
+
'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required'],
|
|
10
|
+
'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required'],
|
|
11
|
+
'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required'],
|
|
12
|
+
'remote-materialization-intent': ['prepared', 'manual-intervention-required'],
|
|
13
|
+
prepared: ['github-promote-intent', 'manual-intervention-required'],
|
|
14
|
+
'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required'],
|
|
15
|
+
'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required'],
|
|
16
|
+
'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required'],
|
|
17
|
+
'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required'],
|
|
18
|
+
'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required'],
|
|
19
|
+
'compensation-intent': ['compensated', 'manual-intervention-required'],
|
|
20
|
+
compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required'],
|
|
21
|
+
'defaults-promoted': ['finalize-intent', 'manual-intervention-required'],
|
|
22
|
+
'finalize-intent': ['channels-converged', 'manual-intervention-required'],
|
|
23
|
+
'manual-intervention-required': [],
|
|
24
|
+
'channels-converged': [],
|
|
25
|
+
aborted: [],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const canonicalJson = (value) => {
|
|
29
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
9
30
|
if (value && typeof value === 'object') {
|
|
10
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${
|
|
31
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
11
32
|
}
|
|
12
33
|
return JSON.stringify(value);
|
|
13
34
|
};
|
|
14
35
|
|
|
15
36
|
export const receiptPayload = (receipt) => {
|
|
16
37
|
const { signature: _signature, receiptDigest: _digest, ...payload } = receipt;
|
|
17
|
-
return
|
|
38
|
+
return canonicalJson(payload);
|
|
18
39
|
};
|
|
19
40
|
|
|
20
41
|
export const digestReceipt = (receipt) => crypto.createHash('sha256')
|
|
21
42
|
.update(receiptPayload(receipt)).digest('hex');
|
|
22
43
|
|
|
23
|
-
export const transactionIdFor = (identity) => crypto.createHash('sha256').update(
|
|
44
|
+
export const transactionIdFor = (identity) => crypto.createHash('sha256').update(canonicalJson({
|
|
45
|
+
schemaVersion: 2,
|
|
24
46
|
repository: identity.repository,
|
|
25
47
|
package: identity.package,
|
|
26
48
|
version: identity.version,
|
|
27
49
|
tag: identity.tag,
|
|
28
50
|
candidateSha: identity.candidateSha,
|
|
51
|
+
payloadId: identity.payloadId || null,
|
|
52
|
+
evidenceDigest: identity.evidenceDigest || null,
|
|
29
53
|
packageIntegrity: identity.packageIntegrity,
|
|
54
|
+
packageSha256: identity.packageSha256 || null,
|
|
55
|
+
packageAssetName: identity.packageAssetName || null,
|
|
30
56
|
bundleSha256: identity.bundleSha256,
|
|
57
|
+
bundleSignatureSha256: identity.bundleSignatureSha256 || null,
|
|
58
|
+
bundleDigestSha256: identity.bundleDigestSha256 || null,
|
|
31
59
|
})).digest('hex');
|
|
32
60
|
|
|
33
61
|
export function signReceipt(receipt, privateKey) {
|
|
34
62
|
const unsigned = { ...receipt, receiptDigest: digestReceipt(receipt) };
|
|
35
63
|
return {
|
|
36
64
|
...unsigned,
|
|
37
|
-
signature: crypto.sign(null, Buffer.from(
|
|
65
|
+
signature: crypto.sign(null, Buffer.from(canonicalJson(unsigned)), privateKey).toString('base64'),
|
|
38
66
|
};
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
export function verifyReceipt(receipt, publicKey) {
|
|
42
|
-
if (!receipt || receipt.schemaVersion
|
|
70
|
+
if (!receipt || ![1, 2].includes(receipt.schemaVersion) || !/^[a-f0-9]{64}$/.test(receipt.transactionId || '')) {
|
|
43
71
|
throw new Error('invalid release transaction receipt');
|
|
44
72
|
}
|
|
45
73
|
const { signature, receiptDigest, ...unsigned } = receipt;
|
|
46
74
|
if (digestReceipt(unsigned) !== receiptDigest) throw new Error('release receipt digest mismatch');
|
|
47
|
-
if (!crypto.verify(null, Buffer.from(
|
|
75
|
+
if (!crypto.verify(null, Buffer.from(canonicalJson({ ...unsigned, receiptDigest })), publicKey, Buffer.from(signature || '', 'base64'))) {
|
|
48
76
|
throw new Error('release receipt signature mismatch');
|
|
49
77
|
}
|
|
50
78
|
return receipt;
|
|
@@ -56,7 +84,7 @@ export function validateReceiptChain(receipts, identity, publicKey) {
|
|
|
56
84
|
let previous = null;
|
|
57
85
|
for (let index = 0; index < sorted.length; index += 1) {
|
|
58
86
|
const receipt = verifyReceipt(sorted[index], publicKey);
|
|
59
|
-
if (receipt.transactionId !== expectedId ||
|
|
87
|
+
if (receipt.transactionId !== expectedId || canonicalJson(receipt.identity) !== canonicalJson(identity)) {
|
|
60
88
|
throw new Error('release receipt identity conflict');
|
|
61
89
|
}
|
|
62
90
|
if (receipt.sequence !== index) throw new Error('release receipt sequence gap or replay');
|
|
@@ -68,13 +96,12 @@ export function validateReceiptChain(receipts, identity, publicKey) {
|
|
|
68
96
|
return sorted;
|
|
69
97
|
}
|
|
70
98
|
|
|
71
|
-
const stateReceipt = ({ identity, prior, state,
|
|
72
|
-
schemaVersion:
|
|
99
|
+
const stateReceipt = ({ identity, prior, state, observation = {}, privateKey }) => signReceipt({
|
|
100
|
+
schemaVersion: 2,
|
|
73
101
|
transactionId: transactionIdFor(identity),
|
|
74
102
|
sequence: prior ? prior.sequence + 1 : 0,
|
|
75
103
|
previousReceiptDigest: prior?.receiptDigest || null,
|
|
76
104
|
state,
|
|
77
|
-
fence,
|
|
78
105
|
identity,
|
|
79
106
|
observation,
|
|
80
107
|
createdAt: new Date().toISOString(),
|
|
@@ -84,6 +111,94 @@ const exact = (actual, expected, label) => {
|
|
|
84
111
|
if (actual !== expected) throw new Error(`${label} mismatch: ${actual ?? '(missing)'} != ${expected}`);
|
|
85
112
|
};
|
|
86
113
|
|
|
114
|
+
const npmCandidateExact = (snapshot, identity) => snapshot?.npm?.candidateVersion === identity.version
|
|
115
|
+
&& snapshot?.npm?.candidateIntegrity === identity.packageIntegrity
|
|
116
|
+
&& snapshot?.npm?.candidateTagVersion === identity.version;
|
|
117
|
+
const npmLatestIsB = (snapshot, identity) => snapshot?.npm?.latestVersion === identity.version;
|
|
118
|
+
const githubIsB = (snapshot, identity) => snapshot?.github?.sha === identity.candidateSha
|
|
119
|
+
&& snapshot?.github?.tag === identity.tag;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pure, total next-action selector. Receipt history is an audit chain, never permission to skip a
|
|
123
|
+
* provider postcondition. Every invocation consumes one fresh cross-provider observation.
|
|
124
|
+
*/
|
|
125
|
+
export function reduceReleaseState({ lastReceipt, snapshot, identity, prior }) {
|
|
126
|
+
if (!lastReceipt || !snapshot || snapshot.readError) return { action: 'manual', reason: 'provider observation unavailable' };
|
|
127
|
+
const assetsExact = snapshot.github?.assetsExact === true;
|
|
128
|
+
const candidateExact = npmCandidateExact(snapshot, identity);
|
|
129
|
+
const npmB = npmLatestIsB(snapshot, identity);
|
|
130
|
+
const githubB = githubIsB(snapshot, identity);
|
|
131
|
+
const githubPublished = githubB && snapshot.github?.published === true;
|
|
132
|
+
const githubLatest = githubPublished && snapshot.github?.latest === true;
|
|
133
|
+
|
|
134
|
+
if (snapshot.npm?.candidateVersion === identity.version && snapshot.npm?.candidateIntegrity
|
|
135
|
+
&& snapshot.npm.candidateIntegrity !== identity.packageIntegrity) {
|
|
136
|
+
return { action: 'manual', reason: 'immutable npm candidate bytes mismatch' };
|
|
137
|
+
}
|
|
138
|
+
if (snapshot.github?.tag === identity.tag && snapshot.github?.sha
|
|
139
|
+
&& snapshot.github.sha !== identity.candidateSha) {
|
|
140
|
+
return { action: 'manual', reason: 'GitHub tag resolves to competing SHA' };
|
|
141
|
+
}
|
|
142
|
+
if (snapshot.github?.latestTag && snapshot.github.latestTag !== identity.tag
|
|
143
|
+
&& snapshot.github.latestTag !== prior?.githubLatest) {
|
|
144
|
+
return { action: 'manual', reason: 'GitHub latest is neither captured A nor candidate B' };
|
|
145
|
+
}
|
|
146
|
+
if (npmB && snapshot.github?.draft === true) return { action: 'compensate-npm' };
|
|
147
|
+
if (npmB && githubLatest) {
|
|
148
|
+
if (lastReceipt.state === 'channels-converged') {
|
|
149
|
+
return githubPublished && assetsExact && candidateExact
|
|
150
|
+
&& snapshot.publicReceiptExact && snapshot.publicHostsExact
|
|
151
|
+
? { action: 'complete' }
|
|
152
|
+
: { action: 'manual', reason: 'terminal public state drifted' };
|
|
153
|
+
}
|
|
154
|
+
return { action: 'finalize' };
|
|
155
|
+
}
|
|
156
|
+
if (!assetsExact) return { action: 'upload-assets' };
|
|
157
|
+
if (!candidateExact) return { action: 'stage-npm' };
|
|
158
|
+
if (!githubPublished) return { action: 'publish-github-nonlatest' };
|
|
159
|
+
if (!npmB) {
|
|
160
|
+
if (snapshot.npm?.latestVersion !== prior?.npmLatest) {
|
|
161
|
+
return { action: 'manual', reason: 'npm latest is neither captured A nor candidate B' };
|
|
162
|
+
}
|
|
163
|
+
return { action: 'promote-npm' };
|
|
164
|
+
}
|
|
165
|
+
if (!githubLatest) return { action: 'make-github-latest' };
|
|
166
|
+
return { action: 'manual', reason: 'unclassified provider state' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function pollObservation(read, predicate, {
|
|
170
|
+
maxElapsedMs = 120_000,
|
|
171
|
+
maxAttempts = 12,
|
|
172
|
+
initialDelayMs = 1_000,
|
|
173
|
+
maxDelayMs = 15_000,
|
|
174
|
+
multiplier = 1.8,
|
|
175
|
+
jitter = () => 0,
|
|
176
|
+
now = () => Date.now(),
|
|
177
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
178
|
+
} = {}) {
|
|
179
|
+
const started = now();
|
|
180
|
+
let delay = initialDelayMs;
|
|
181
|
+
let last;
|
|
182
|
+
let lastError;
|
|
183
|
+
for (let attempt = 1; attempt <= maxAttempts && now() - started <= maxElapsedMs; attempt += 1) {
|
|
184
|
+
try {
|
|
185
|
+
last = await read();
|
|
186
|
+
const elapsedMs = now() - started;
|
|
187
|
+
if (predicate(last) && elapsedMs <= maxElapsedMs) return { value: last, attempt, elapsedMs };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lastError = error;
|
|
190
|
+
}
|
|
191
|
+
if (attempt === maxAttempts) break;
|
|
192
|
+
const remaining = maxElapsedMs - (now() - started);
|
|
193
|
+
if (remaining <= 0) break;
|
|
194
|
+
const wait = Math.max(0, Math.min(remaining, maxDelayMs, delay + jitter(delay, attempt)));
|
|
195
|
+
await sleep(wait);
|
|
196
|
+
delay = Math.min(maxDelayMs, Math.ceil(delay * multiplier));
|
|
197
|
+
}
|
|
198
|
+
const detail = lastError?.message || canonicalJson(last);
|
|
199
|
+
throw new Error(`visibility deadline exceeded after ${now() - started}ms: ${detail}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
87
202
|
export async function runReleaseTransaction({ identity, assets, adapter, privateKey, publicKey, hostVerifier }) {
|
|
88
203
|
const expectedId = transactionIdFor(identity);
|
|
89
204
|
const discovered = await adapter.discover(identity);
|
|
@@ -93,111 +208,180 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
93
208
|
|
|
94
209
|
let chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
|
|
95
210
|
let current = chain.at(-1) || null;
|
|
96
|
-
const
|
|
97
|
-
const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity, fence);
|
|
98
|
-
const completed = new Set(chain.map(({ state }) => state));
|
|
211
|
+
const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity);
|
|
99
212
|
const append = async (state, observation = {}) => {
|
|
100
|
-
const
|
|
213
|
+
const recoveryCompensation = state === 'compensation-intent' && current
|
|
214
|
+
&& !TERMINAL_STATES.has(current.state) && current.state !== 'manual-intervention-required';
|
|
215
|
+
if (current && !ALLOWED_TRANSITIONS[current.state]?.includes(state) && current.state !== state
|
|
216
|
+
&& !recoveryCompensation) {
|
|
217
|
+
throw new Error(`illegal release transition ${current.state} -> ${state}`);
|
|
218
|
+
}
|
|
219
|
+
const receipt = stateReceipt({ identity, prior: current, state, observation, privateKey });
|
|
101
220
|
await adapter.appendReceipt(draft, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`);
|
|
102
221
|
const observed = await adapter.readReceipt(draft, receipt.sequence);
|
|
103
222
|
verifyReceipt(observed, publicKey);
|
|
104
223
|
exact(observed.receiptDigest, receipt.receiptDigest, 'remote receipt');
|
|
105
224
|
current = receipt;
|
|
106
|
-
completed.add(state);
|
|
107
225
|
return receipt;
|
|
108
226
|
};
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
227
|
+
const transition = async (state, observation = {}) => {
|
|
228
|
+
if (current?.state !== state) await append(state, observation);
|
|
111
229
|
};
|
|
112
230
|
|
|
113
|
-
if (!current) await append('remote-prepared', {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await intend('host-verification-intent', { source: 'sealed-local-assets' });
|
|
121
|
-
const localHosts = await hostVerifier.verify({ source: 'local', identity, assets });
|
|
122
|
-
if (localHosts.verdict !== 'PASS') throw new Error('local staged host verification failed');
|
|
123
|
-
await append('local-hosts-verified', { hosts: localHosts });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!completed.has('npm-candidate-staged')) {
|
|
127
|
-
await intend('npm-stage-intent');
|
|
128
|
-
await adapter.stageNpm(identity, assets.packagePath);
|
|
129
|
-
const stagedNpm = await adapter.observeNpmCandidate(identity);
|
|
130
|
-
exact(stagedNpm.version, identity.version, 'npm candidate version');
|
|
131
|
-
exact(stagedNpm.integrity, identity.packageIntegrity, 'npm candidate integrity');
|
|
132
|
-
await append('npm-candidate-staged', { npm: stagedNpm });
|
|
133
|
-
}
|
|
231
|
+
if (!current) await append('remote-prepared', {
|
|
232
|
+
draftId: draft.id,
|
|
233
|
+
prior: discovered.prior,
|
|
234
|
+
reconciledLegacyTransactions: discovered.legacySettled || [],
|
|
235
|
+
});
|
|
236
|
+
const prior = chain[0]?.observation?.prior || current.observation?.prior || discovered.prior;
|
|
237
|
+
if (!prior?.npmLatest || !prior?.githubLatest) throw new Error('sequence-zero prior generation is incomplete');
|
|
134
238
|
|
|
135
|
-
|
|
136
|
-
await
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
staged.cleanup();
|
|
239
|
+
for (let step = 0; step < 32; step += 1) {
|
|
240
|
+
const snapshot = await adapter.observeSnapshot(identity, draft, {
|
|
241
|
+
forceAssets: current.state === 'finalize-intent' || current.state === 'channels-converged',
|
|
242
|
+
});
|
|
243
|
+
if (current.state === 'channels-converged') {
|
|
244
|
+
const terminal = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
|
|
245
|
+
if (terminal.action === 'complete') return current;
|
|
246
|
+
throw new Error(`terminal release drift: ${terminal.reason || terminal.action}`);
|
|
144
247
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
248
|
+
// A process may die after the provider side effect and before its observation receipt. Rebuild
|
|
249
|
+
// that missing receipt from fresh state before asking the reducer for the next command.
|
|
250
|
+
if (current.state === 'npm-stage-intent' && npmCandidateExact(snapshot, identity)) {
|
|
251
|
+
await transition('npm-candidate-staged', { npm: snapshot.npm, recovered: true });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (current.state === 'remote-materialization-intent' && snapshot.github?.assetsExact
|
|
255
|
+
&& npmCandidateExact(snapshot, identity)) {
|
|
256
|
+
await transition('prepared', { recovered: true });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (current.state === 'github-promote-intent' && githubIsB(snapshot, identity)
|
|
260
|
+
&& snapshot.github?.published && !snapshot.github?.latest) {
|
|
261
|
+
await transition('github-promoted-nonlatest', { github: snapshot.github, recovered: true });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (current.state === 'npm-promote-intent' && npmLatestIsB(snapshot, identity)) {
|
|
265
|
+
await transition('npm-promoted', { npm: snapshot.npm, recovered: true });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (current.state === 'github-latest-intent' && npmLatestIsB(snapshot, identity)
|
|
269
|
+
&& githubIsB(snapshot, identity) && snapshot.github?.latest) {
|
|
270
|
+
await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github, recovered: true });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (current.state === 'compensation-intent' && snapshot.npm?.latestVersion === prior.npmLatest) {
|
|
274
|
+
await transition('compensated', { npm: snapshot.npm, github: snapshot.github, recovered: true });
|
|
275
|
+
throw new Error('npm compensation recovered; resume the same transaction');
|
|
276
|
+
}
|
|
277
|
+
if (current.state === 'finalize-intent' && snapshot.publicReceiptExact && snapshot.publicHostsExact
|
|
278
|
+
&& npmCandidateExact(snapshot, identity) && npmLatestIsB(snapshot, identity)
|
|
279
|
+
&& githubIsB(snapshot, identity) && snapshot.github?.published && snapshot.github?.latest
|
|
280
|
+
&& snapshot.github?.assetsExact) {
|
|
281
|
+
return append('channels-converged', { verdict: 'PASS', recovered: true });
|
|
282
|
+
}
|
|
283
|
+
const decision = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
|
|
284
|
+
const stageRecord = {
|
|
285
|
+
event: 'release-stage',
|
|
286
|
+
transactionId: expectedId,
|
|
287
|
+
payloadId: identity.payloadId || null,
|
|
288
|
+
sequence: current.sequence,
|
|
289
|
+
state: current.state,
|
|
290
|
+
action: decision.action,
|
|
291
|
+
};
|
|
292
|
+
console.log(process.env.GITHUB_ACTIONS === 'true'
|
|
293
|
+
? `::notice title=Release stage ${decision.action}::${JSON.stringify(stageRecord)}`
|
|
294
|
+
: JSON.stringify(stageRecord));
|
|
295
|
+
if (decision.action === 'complete') return current;
|
|
296
|
+
if (decision.action === 'manual') {
|
|
297
|
+
await transition('manual-intervention-required', { reason: decision.reason, snapshot });
|
|
298
|
+
throw new Error(`release requires manual intervention: ${decision.reason}`);
|
|
299
|
+
}
|
|
300
|
+
if (decision.action === 'upload-assets') {
|
|
301
|
+
await transition('asset-upload-intent', { payloadId: identity.payloadId || null });
|
|
302
|
+
await adapter.uploadAssets(draft, assets, identity);
|
|
303
|
+
const observed = await adapter.observeSnapshot(identity, draft);
|
|
304
|
+
if (!observed.github?.assetsExact) throw new Error('staged GitHub payload mismatch');
|
|
305
|
+
await transition('npm-stage-intent', { github: observed.github });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (decision.action === 'stage-npm') {
|
|
309
|
+
await transition('npm-stage-intent');
|
|
310
|
+
await adapter.stageNpm(identity, assets.packagePath);
|
|
311
|
+
const polled = await pollObservation(
|
|
312
|
+
async () => ({ npm: await adapter.observeNpm(identity) }),
|
|
313
|
+
(value) => npmCandidateExact(value, identity),
|
|
314
|
+
adapter.observationPolicy,
|
|
315
|
+
);
|
|
316
|
+
await transition('npm-candidate-staged', { npm: polled.value.npm, visibility: polled });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (decision.action === 'publish-github-nonlatest') {
|
|
320
|
+
if (current.state === 'npm-candidate-staged') await transition('remote-materialization-intent');
|
|
321
|
+
if (current.state === 'remote-materialization-intent') {
|
|
322
|
+
const staged = await adapter.materializeStagedAssets(draft, identity);
|
|
323
|
+
try { await adapter.verifyMaterializedPayload?.(staged.assets, identity); } finally { staged.cleanup(); }
|
|
324
|
+
await transition('prepared');
|
|
325
|
+
}
|
|
326
|
+
await transition('github-promote-intent');
|
|
327
|
+
await adapter.publishDraftNonLatest(draft, identity);
|
|
328
|
+
const observed = await adapter.observeSnapshot(identity, draft);
|
|
329
|
+
if (!(githubIsB(observed, identity) && observed.github.published && !observed.github.latest)) {
|
|
330
|
+
throw new Error('GitHub non-latest publication not observed');
|
|
331
|
+
}
|
|
332
|
+
await transition('github-promoted-nonlatest', { github: observed.github });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (decision.action === 'promote-npm') {
|
|
336
|
+
await transition('npm-promote-intent');
|
|
337
|
+
await adapter.promoteNpm(identity, prior.npmLatest);
|
|
338
|
+
const polled = await pollObservation(
|
|
339
|
+
async () => ({ npm: await adapter.observeNpm(identity) }),
|
|
340
|
+
(value) => npmLatestIsB(value, identity),
|
|
341
|
+
adapter.observationPolicy,
|
|
342
|
+
);
|
|
343
|
+
await transition('npm-promoted', { npm: polled.value.npm, visibility: polled });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (decision.action === 'make-github-latest') {
|
|
347
|
+
await transition('github-latest-intent');
|
|
348
|
+
await adapter.makeGithubLatest(draft, identity, prior.githubLatest);
|
|
349
|
+
const observed = await adapter.observeSnapshot(identity, draft);
|
|
350
|
+
if (!(npmLatestIsB(observed, identity) && observed.github?.latest && githubIsB(observed, identity))) {
|
|
351
|
+
throw new Error('provider defaults not jointly observed at candidate B');
|
|
352
|
+
}
|
|
353
|
+
await transition('defaults-promoted', { npm: observed.npm, github: observed.github });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (decision.action === 'compensate-npm') {
|
|
357
|
+
await transition('compensation-intent', { restore: prior.npmLatest });
|
|
358
|
+
await adapter.restoreNpmLatest(prior.npmLatest, identity.version);
|
|
359
|
+
const polled = await pollObservation(
|
|
360
|
+
() => adapter.observeNpm(identity),
|
|
361
|
+
(npm) => npm?.latestVersion === prior.npmLatest,
|
|
362
|
+
adapter.observationPolicy,
|
|
363
|
+
);
|
|
364
|
+
await transition('compensated', { npm: polled.value, github: snapshot.github, visibility: polled });
|
|
365
|
+
throw new Error('npm compensated to captured prior generation; resume the same transaction');
|
|
366
|
+
}
|
|
367
|
+
if (decision.action === 'finalize') {
|
|
368
|
+
if (current.state !== 'defaults-promoted' && current.state !== 'finalize-intent') {
|
|
369
|
+
await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github });
|
|
370
|
+
}
|
|
371
|
+
await transition('finalize-intent');
|
|
372
|
+
const final = await adapter.finalize(identity, current, hostVerifier);
|
|
373
|
+
if (final.verdict !== 'PASS') throw new Error('final release convergence failed');
|
|
374
|
+
const reobserved = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
|
|
375
|
+
if (!(npmLatestIsB(reobserved, identity) && reobserved.github?.latest && githubIsB(reobserved, identity))) {
|
|
376
|
+
throw new Error('provider defaults drifted during finalization');
|
|
377
|
+
}
|
|
378
|
+
if (!reobserved.github?.assetsExact || !reobserved.publicReceiptExact || !reobserved.publicHostsExact) {
|
|
379
|
+
throw new Error('public receipt, host, or artifact evidence drifted during finalization');
|
|
380
|
+
}
|
|
381
|
+
return append('channels-converged', final);
|
|
162
382
|
}
|
|
163
|
-
const npmLatest = await adapter.observeNpmLatest();
|
|
164
|
-
exact(npmLatest.version, identity.version, 'npm latest');
|
|
165
|
-
await append('npm-promoted', { npm: npmLatest });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// A failed GitHub-latest promotion is compensated back to A. The historical npm-promoted
|
|
169
|
-
// receipt remains true, but is no longer the current external state, so a clean-runner retry
|
|
170
|
-
// must explicitly re-promote B before it may retry GitHub.
|
|
171
|
-
if (current.state === 'compensated') {
|
|
172
|
-
await append('npm-repromote-intent');
|
|
173
|
-
await adapter.promoteNpm(identity);
|
|
174
|
-
const npmLatest = await adapter.observeNpmLatest();
|
|
175
|
-
exact(npmLatest.version, identity.version, 'npm latest after compensation retry');
|
|
176
|
-
await append('npm-repromoted', { npm: npmLatest });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!completed.has('defaults-promoted')) {
|
|
180
|
-
await intend('github-latest-intent');
|
|
181
|
-
try {
|
|
182
|
-
await adapter.makeGithubLatest(draft, identity);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
const observed = await adapter.observeNpmLatest();
|
|
185
|
-
if (observed.version !== identity.version) throw new Error('npm latest changed during compensation');
|
|
186
|
-
await intend('compensation-intent', { restore: discovered.prior?.npmLatest });
|
|
187
|
-
await adapter.restoreNpmLatest(discovered.prior?.npmLatest, identity.version);
|
|
188
|
-
exact((await adapter.observeNpmLatest()).version, discovered.prior?.npmLatest, 'npm compensation');
|
|
189
|
-
await append('compensated', { reason: error.message });
|
|
190
|
-
throw new Error(`GitHub latest promotion failed; npm compensated: ${error.message}`);
|
|
191
|
-
}
|
|
192
|
-
const githubLatest = await adapter.observeGithubLatest();
|
|
193
|
-
exact(githubLatest.tag, identity.tag, 'GitHub latest');
|
|
194
|
-
await append('defaults-promoted', { github: githubLatest });
|
|
195
383
|
}
|
|
196
|
-
|
|
197
|
-
await intend('finalize-intent');
|
|
198
|
-
const final = await adapter.finalize(identity, current, hostVerifier);
|
|
199
|
-
if (final.verdict !== 'PASS') throw new Error('final release convergence failed');
|
|
200
|
-
return append('channels-converged', final);
|
|
384
|
+
throw new Error('release reducer exceeded its bounded transition count');
|
|
201
385
|
}
|
|
202
386
|
|
|
203
387
|
export async function abortReleaseTransaction({ identity, receipts, reason, authorized, adapter, privateKey, publicKey }) {
|
|
@@ -205,9 +389,15 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
|
|
|
205
389
|
const chain = validateReceiptChain(receipts, identity, publicKey);
|
|
206
390
|
const current = chain.at(-1);
|
|
207
391
|
if (!current || TERMINAL_STATES.has(current.state)) throw new Error('release transaction is not abortable');
|
|
392
|
+
const snapshot = await adapter.observeSnapshot(identity);
|
|
393
|
+
const prior = chain[0]?.observation?.prior;
|
|
394
|
+
if (!prior || snapshot.npm?.latestVersion === identity.version || snapshot.github?.latest === true
|
|
395
|
+
|| snapshot.npm?.latestVersion !== prior.npmLatest || snapshot.github?.latestTag !== prior.githubLatest) {
|
|
396
|
+
throw new Error('release abort cannot prove both defaults at captured prior generation');
|
|
397
|
+
}
|
|
208
398
|
const receipt = stateReceipt({
|
|
209
|
-
identity, prior: current, state: 'aborted',
|
|
210
|
-
observation: { reason, authorized: true }, privateKey,
|
|
399
|
+
identity, prior: current, state: 'aborted',
|
|
400
|
+
observation: { reason, authorized: true, snapshot }, privateKey,
|
|
211
401
|
});
|
|
212
402
|
await adapter.appendReceipt(null, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`);
|
|
213
403
|
return receipt;
|
package/scripts/release.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { validateProtectedPublishInvocation } from './protected-release-invocati
|
|
|
33
33
|
import { runReleaseTransaction } from './release-transaction.mjs';
|
|
34
34
|
import { liveReleaseProvider } from './release-transaction-provider.mjs';
|
|
35
35
|
import { stagedHostVerifier } from './staged-host-verifier.mjs';
|
|
36
|
+
import { verifyPayload } from './release-payload.mjs';
|
|
36
37
|
|
|
37
38
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
38
39
|
const PUBLISH = process.argv.includes('--publish');
|
|
@@ -40,6 +41,8 @@ let protectedCandidate = null;
|
|
|
40
41
|
let sealedPackageArtifact = null;
|
|
41
42
|
let publicationReceiptPath = null;
|
|
42
43
|
let protectedReleaseMode = 'strict';
|
|
44
|
+
let verifiedPayload = null;
|
|
45
|
+
let aggregateEnvelope = null;
|
|
43
46
|
const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
|
|
44
47
|
const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
|
|
45
48
|
|
|
@@ -75,6 +78,23 @@ if (PUBLISH) {
|
|
|
75
78
|
console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
|
|
76
79
|
process.exit(1);
|
|
77
80
|
}
|
|
81
|
+
const payloadManifestPath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD || '');
|
|
82
|
+
const payloadSignaturePath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD_SIGNATURE || '');
|
|
83
|
+
const aggregatePath = path.resolve(ROOT, process.env.RUVNET_AGGREGATE_ENVELOPE || '');
|
|
84
|
+
if (![payloadManifestPath, payloadSignaturePath, aggregatePath].every((file) => file.startsWith(`${evidenceRoot}${path.sep}`) && fs.existsSync(file))) {
|
|
85
|
+
throw new Error('protected publication requires persisted payload manifest, signature, and aggregate envelope');
|
|
86
|
+
}
|
|
87
|
+
verifiedPayload = verifyPayload({
|
|
88
|
+
manifest: JSON.parse(fs.readFileSync(payloadManifestPath, 'utf8')),
|
|
89
|
+
signature: fs.readFileSync(payloadSignaturePath, 'utf8'),
|
|
90
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
|
|
91
|
+
root: path.dirname(payloadManifestPath),
|
|
92
|
+
});
|
|
93
|
+
aggregateEnvelope = JSON.parse(fs.readFileSync(aggregatePath, 'utf8'));
|
|
94
|
+
if (aggregateEnvelope.verdict !== 'PASS' || aggregateEnvelope.sha !== protectedCandidate.sha
|
|
95
|
+
|| aggregateEnvelope.payloadId !== verifiedPayload.payloadId) {
|
|
96
|
+
throw new Error('aggregate envelope does not bind the protected candidate payload');
|
|
97
|
+
}
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
// A verdict is only about the exact committed candidate. Check-only used to permit a dirty tree
|
|
@@ -166,18 +186,15 @@ if (!PUBLISH) {
|
|
|
166
186
|
if (PUBLISH) {
|
|
167
187
|
const v = V();
|
|
168
188
|
const tag = `v${v}`;
|
|
169
|
-
const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
|
|
170
189
|
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
runOrDie('build release bundle', process.execPath, buildArgs);
|
|
175
|
-
runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
|
|
190
|
+
step('D', 'verify, stage, promote, and reconcile one persisted signed payload');
|
|
191
|
+
const payloadRoot = verifiedPayload.root;
|
|
192
|
+
const byRole = new Map(verifiedPayload.manifest.members.map((member) => [member.role, path.join(payloadRoot, member.name)]));
|
|
176
193
|
const assets = {
|
|
177
|
-
bundlePath:
|
|
178
|
-
bundleSignaturePath:
|
|
179
|
-
bundleDigestPath:
|
|
180
|
-
packagePath:
|
|
194
|
+
bundlePath: byRole.get('bundle'),
|
|
195
|
+
bundleSignaturePath: path.join(payloadRoot, 'ruvnet-brain.zip.sig'),
|
|
196
|
+
bundleDigestPath: path.join(payloadRoot, 'ruvnet-brain.zip.sha256'),
|
|
197
|
+
packagePath: byRole.get('npm'),
|
|
181
198
|
};
|
|
182
199
|
for (const asset of Object.values(assets)) {
|
|
183
200
|
if (!fs.existsSync(asset)) {
|
|
@@ -185,16 +202,28 @@ if (PUBLISH) {
|
|
|
185
202
|
process.exit(1);
|
|
186
203
|
}
|
|
187
204
|
}
|
|
188
|
-
const bundleSha256 = fs.readFileSync(
|
|
205
|
+
const bundleSha256 = fs.readFileSync(assets.bundleDigestPath, 'utf8').trim().split(/\s+/)[0];
|
|
189
206
|
if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
|
|
190
207
|
console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);
|
|
191
208
|
process.exit(1);
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
const packageIntegrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(sealedPackageArtifact)).digest('base64')}`;
|
|
212
|
+
if (assets.packagePath !== sealedPackageArtifact
|
|
213
|
+
&& !fs.readFileSync(assets.packagePath).equals(fs.readFileSync(sealedPackageArtifact))) {
|
|
214
|
+
throw new Error('candidate receipt package and payload package bytes differ');
|
|
215
|
+
}
|
|
195
216
|
const identity = {
|
|
196
217
|
repository: 'stuinfla/ruvnet-brain', package: 'ruvnet-brain', version: v, tag,
|
|
197
|
-
candidateSha: head,
|
|
218
|
+
candidateSha: head,
|
|
219
|
+
payloadId: verifiedPayload.payloadId,
|
|
220
|
+
evidenceDigest: aggregateEnvelope.evidenceDigest,
|
|
221
|
+
packageIntegrity,
|
|
222
|
+
packageSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.packagePath)).digest('hex'),
|
|
223
|
+
packageAssetName: path.basename(assets.packagePath),
|
|
224
|
+
bundleSha256,
|
|
225
|
+
bundleSignatureSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleSignaturePath)).digest('hex'),
|
|
226
|
+
bundleDigestSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleDigestPath)).digest('hex'),
|
|
198
227
|
};
|
|
199
228
|
const privatePem = process.env.RUVNET_SIGNING_KEY;
|
|
200
229
|
if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -159,17 +159,31 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
|
|
|
159
159
|
for (const v of fs.readdirSync(cache)) {
|
|
160
160
|
const root = path.join(cache, v);
|
|
161
161
|
if (fs.existsSync(path.join(root, 'hooks', 'hooks.json'))) {
|
|
162
|
-
candidates.push({ root, source: `installed:${v}`, mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
|
|
162
|
+
candidates.push({ root, source: `installed:${v}`, hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
} catch { /* no packed install on this machine */ }
|
|
166
166
|
candidates.sort((a, b) => b.mtime - a.mtime); // newest generation wins; several can coexist
|
|
167
167
|
const clone = path.join(home, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
|
|
168
168
|
if (fs.existsSync(path.join(clone, 'hooks', 'hooks.json'))) {
|
|
169
|
-
candidates.push({ root: clone, source: 'marketplace-clone', mtime: 0 });
|
|
169
|
+
candidates.push({ root: clone, source: 'marketplace-clone', hooksFile: path.join(clone, 'hooks', 'hooks.json'), mtime: 0 });
|
|
170
170
|
}
|
|
171
|
+
// Codex installs the same immutable payload under its own cache. A Codex-only machine has no
|
|
172
|
+
// Claude marketplace clone, so treating that layout as "no plugin" made the real installed
|
|
173
|
+
// Codex hooks impossible to self-check (the release host matrix caught this). Keep the same
|
|
174
|
+
// newest-generation rule and read the Codex hook registry from the installed payload itself.
|
|
175
|
+
const codexCache = path.join(home, '.codex', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
|
|
176
|
+
try {
|
|
177
|
+
for (const v of fs.readdirSync(codexCache)) {
|
|
178
|
+
const root = path.join(codexCache, v);
|
|
179
|
+
if (fs.existsSync(path.join(root, 'hooks', 'codex-hooks.json'))) {
|
|
180
|
+
candidates.push({ root, source: `codex-installed:${v}`, hooksFile: path.join(root, 'hooks', 'codex-hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'codex-hooks.json')).mtimeMs, codex: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch { /* no Codex plugin cache on this machine */ }
|
|
171
184
|
if (repo && fs.existsSync(path.join(repo, 'plugin', 'hooks', 'hooks.json'))) {
|
|
172
|
-
|
|
185
|
+
const root = path.join(repo, 'plugin');
|
|
186
|
+
candidates.push({ root, source: 'checkout', hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: 0 });
|
|
173
187
|
}
|
|
174
188
|
const chosen = candidates[0];
|
|
175
189
|
if (!chosen) return { ok: false, reason: 'no installed ruvnet-brain plugin payload found on this machine' };
|
|
@@ -177,8 +191,9 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
|
|
|
177
191
|
ok: true,
|
|
178
192
|
root: chosen.root,
|
|
179
193
|
source: chosen.source,
|
|
180
|
-
hooksFile:
|
|
194
|
+
hooksFile: chosen.hooksFile,
|
|
181
195
|
shimFile: path.join(chosen.root, 'scripts', 'hook-shim.mjs'),
|
|
196
|
+
codex: Boolean(chosen.codex),
|
|
182
197
|
alternates: candidates.slice(1).map((c) => c.source),
|
|
183
198
|
};
|
|
184
199
|
}
|
|
@@ -480,7 +495,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
|
|
|
480
495
|
const shimId = reg.shimIdIn(r.command);
|
|
481
496
|
const entry = shimId ? table[shimId] : null;
|
|
482
497
|
const contract = entry ? null : contracts.find((c) => reg.contractMatches(c, { layer: 'plugin', event: r.event, matcher: r.matcher, command: r.command }));
|
|
483
|
-
const mode = entry?.mode ?? contract?.mode ?? null;
|
|
498
|
+
const mode = entry?.mode ?? contract?.mode ?? (s.codex ? 'advisory' : null);
|
|
484
499
|
const handler = entry?.file ?? reg.basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null;
|
|
485
500
|
const rec = { ...r, handler, shimId, mode };
|
|
486
501
|
|