release-skill 0.2.8 → 0.3.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +38 -13
- package/README.zh-CN.md +36 -13
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +3961 -2882
- package/adapters/claude/skills/release-help/SKILL.md +9 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +3961 -2882
- package/adapters/codex/skills/release-help/SKILL.md +9 -1
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +3961 -2882
- package/adapters/kimi/skills/release-help/SKILL.md +9 -1
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3961 -2882
- package/adapters/workbuddy/skills/release-help/SKILL.md +9 -1
- package/bin/release-skill-cli.mjs +180 -4
- package/bin/release-skill.bundle.mjs +3961 -2882
- package/package.json +1 -1
- package/skills/release-help/SKILL.md +9 -1
- package/skills-src/release-help/SKILL.md +9 -1
- package/src/adapters/plugin-marketplace.mjs +1 -1
- package/src/adapters/push-snapshot.mjs +3 -1
- package/src/commands/assess.mjs +18 -5
- package/src/commands/attest.mjs +195 -0
- package/src/commands/hooks.mjs +46 -0
- package/src/commands/prepare.mjs +14 -2
- package/src/commands/ship.mjs +356 -0
- package/src/commands/verify.mjs +147 -4
- package/src/core/git-transport.mjs +93 -0
- package/src/core/public-surface.mjs +26 -0
- package/src/core/release-metadata.mjs +105 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { readFile, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
5
|
+
import {
|
|
6
|
+
ReleaseError,
|
|
7
|
+
GATE_FAILED,
|
|
8
|
+
PLAN_DIGEST_MISMATCH,
|
|
9
|
+
CONSUMER_VERIFICATION_DEFERRED,
|
|
10
|
+
} from '../core/errors.mjs';
|
|
11
|
+
|
|
12
|
+
async function writeJsonAtomic(path, value) {
|
|
13
|
+
await mkdir(dirname(path), { recursive: true });
|
|
14
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
15
|
+
const { stateDigest: _oldDigest, ...body } = value;
|
|
16
|
+
const sealed = {
|
|
17
|
+
...body,
|
|
18
|
+
stateDigest: sha256Hex(canonicalJson(body)),
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(temp, `${JSON.stringify(sealed, null, 2)}\n`, {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
mode: 0o600,
|
|
24
|
+
flag: 'wx',
|
|
25
|
+
});
|
|
26
|
+
await rename(temp, path);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readState(path) {
|
|
34
|
+
try {
|
|
35
|
+
const stat = await lstat(path);
|
|
36
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
37
|
+
throw new Error('ship state must be a regular non-symlink file');
|
|
38
|
+
}
|
|
39
|
+
const state = JSON.parse(await readFile(path, 'utf8'));
|
|
40
|
+
const { stateDigest, ...body } = state;
|
|
41
|
+
if (
|
|
42
|
+
typeof stateDigest !== 'string'
|
|
43
|
+
|| stateDigest !== sha256Hex(canonicalJson(body))
|
|
44
|
+
|| body.statePath !== path
|
|
45
|
+
) {
|
|
46
|
+
throw new Error('ship state digest or authority path does not match');
|
|
47
|
+
}
|
|
48
|
+
return state;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'ENOENT') return null;
|
|
51
|
+
throw new ReleaseError(GATE_FAILED, `cannot read ship state: ${error.message}`, { statePath: path });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function defaultDependencies() {
|
|
56
|
+
const [
|
|
57
|
+
configModule,
|
|
58
|
+
prepareModule,
|
|
59
|
+
approveModule,
|
|
60
|
+
publishModule,
|
|
61
|
+
reconcileModule,
|
|
62
|
+
verifyModule,
|
|
63
|
+
transportModule,
|
|
64
|
+
metadataModule,
|
|
65
|
+
] = await Promise.all([
|
|
66
|
+
import('../core/config.mjs'),
|
|
67
|
+
import('./prepare.mjs'),
|
|
68
|
+
import('./approve.mjs'),
|
|
69
|
+
import('./publish.mjs'),
|
|
70
|
+
import('./reconcile.mjs'),
|
|
71
|
+
import('./verify.mjs'),
|
|
72
|
+
import('../core/git-transport.mjs'),
|
|
73
|
+
import('../core/release-metadata.mjs'),
|
|
74
|
+
]);
|
|
75
|
+
return {
|
|
76
|
+
loadProjectConfig: configModule.loadProjectConfig,
|
|
77
|
+
prepareRelease: prepareModule.prepareRelease,
|
|
78
|
+
approvePlan: approveModule.approvePlan,
|
|
79
|
+
publishRelease: publishModule.publishRelease,
|
|
80
|
+
reconcileRelease: reconcileModule.reconcileRelease,
|
|
81
|
+
verifyRelease: verifyModule.verifyRelease,
|
|
82
|
+
preflightGitTransports: transportModule.preflightGitTransports,
|
|
83
|
+
updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function publicState(state) {
|
|
88
|
+
return {
|
|
89
|
+
command: 'ship',
|
|
90
|
+
status: state.status,
|
|
91
|
+
statePath: state.statePath,
|
|
92
|
+
targetVersion: state.targetVersion,
|
|
93
|
+
...(state.hookAuthorizationDigest ? {
|
|
94
|
+
hookAuthorizationDigest: state.hookAuthorizationDigest,
|
|
95
|
+
hooks: state.hooks,
|
|
96
|
+
} : {}),
|
|
97
|
+
...(state.planPath ? {
|
|
98
|
+
planPath: state.planPath,
|
|
99
|
+
planDigest: state.planDigest,
|
|
100
|
+
evidenceDir: state.evidenceDir,
|
|
101
|
+
warnings: state.warnings ?? [],
|
|
102
|
+
} : {}),
|
|
103
|
+
...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
|
|
104
|
+
...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
|
|
105
|
+
...(state.requirements ? { requirements: state.requirements } : {}),
|
|
106
|
+
...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
|
|
107
|
+
verificationGateAuthorizationIncludedInPlanApproval:
|
|
108
|
+
state.status !== 'NEEDS_HOOK_AUTHORIZATION',
|
|
109
|
+
postVerifyMetadataUpdateIncludedInPlanApproval:
|
|
110
|
+
state.status !== 'NEEDS_HOOK_AUTHORIZATION',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hookAuthority(loaded, targetVersion) {
|
|
115
|
+
const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
|
|
116
|
+
return {
|
|
117
|
+
hooks,
|
|
118
|
+
digest: sha256Hex(canonicalJson({
|
|
119
|
+
kind: 'release-skill-hook-authorization/v1',
|
|
120
|
+
configDigest: loaded.configDigest,
|
|
121
|
+
hooks: loaded.config.hooks ?? {},
|
|
122
|
+
targetVersion: targetVersion ?? null,
|
|
123
|
+
})),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Advance one durable production release. Re-running is safe: the state file
|
|
129
|
+
* carries the immutable plan, approval and source-run paths so the command
|
|
130
|
+
* resumes instead of reconstructing authority from terminal/chat output.
|
|
131
|
+
*/
|
|
132
|
+
export async function advanceShip(options = {}, injected = {}) {
|
|
133
|
+
const root = resolve(options.root ?? process.cwd());
|
|
134
|
+
const statePath = resolve(
|
|
135
|
+
options.statePath ?? resolve(root, '.release-skill', 'ships', 'current.json'),
|
|
136
|
+
);
|
|
137
|
+
const deps = Object.keys(injected).length > 0
|
|
138
|
+
? injected
|
|
139
|
+
: await defaultDependencies();
|
|
140
|
+
let state = await readState(statePath);
|
|
141
|
+
if (state?.gitTransport) {
|
|
142
|
+
process.env.RELEASE_SKILL_GIT_TRANSPORT = state.gitTransport;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!state) {
|
|
146
|
+
const loaded = await deps.loadProjectConfig({ root });
|
|
147
|
+
const authority = hookAuthority(loaded, options.targetVersion);
|
|
148
|
+
const hooks = authority.hooks;
|
|
149
|
+
const hookAuthorizationDigest = authority.digest;
|
|
150
|
+
state = {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
root,
|
|
153
|
+
statePath,
|
|
154
|
+
targetVersion: options.targetVersion ?? null,
|
|
155
|
+
configDigest: loaded.configDigest,
|
|
156
|
+
hooks,
|
|
157
|
+
hookAuthorizationDigest,
|
|
158
|
+
status: hooks.length > 0 ? 'NEEDS_HOOK_AUTHORIZATION' : 'NEW',
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
updatedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
await writeJsonAtomic(statePath, state);
|
|
163
|
+
if (hooks.length > 0 && options.hookAuthorizationDigest !== hookAuthorizationDigest) {
|
|
164
|
+
return publicState(state);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (state.status === 'NEEDS_HOOK_AUTHORIZATION') {
|
|
169
|
+
const loaded = await deps.loadProjectConfig({ root });
|
|
170
|
+
const current = hookAuthority(loaded, state.targetVersion);
|
|
171
|
+
if (
|
|
172
|
+
loaded.configDigest !== state.configDigest
|
|
173
|
+
|| current.digest !== state.hookAuthorizationDigest
|
|
174
|
+
) {
|
|
175
|
+
state = {
|
|
176
|
+
...state,
|
|
177
|
+
configDigest: loaded.configDigest,
|
|
178
|
+
hooks: current.hooks,
|
|
179
|
+
hookAuthorizationDigest: current.digest,
|
|
180
|
+
updatedAt: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
await writeJsonAtomic(statePath, state);
|
|
183
|
+
if (verified.status === 'VERIFIED' && deps.updatePreviousPublicBaselines) {
|
|
184
|
+
try {
|
|
185
|
+
state.metadataUpdate = await deps.updatePreviousPublicBaselines({
|
|
186
|
+
root,
|
|
187
|
+
planPath: state.planPath,
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
state.metadataUpdate = {
|
|
191
|
+
status: 'FAILED',
|
|
192
|
+
error: error.message,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
state.updatedAt = new Date().toISOString();
|
|
196
|
+
await writeJsonAtomic(statePath, state);
|
|
197
|
+
}
|
|
198
|
+
return publicState(state);
|
|
199
|
+
}
|
|
200
|
+
if (options.hookAuthorizationDigest !== state.hookAuthorizationDigest) {
|
|
201
|
+
if (options.hookAuthorizationDigest) {
|
|
202
|
+
throw new ReleaseError(
|
|
203
|
+
PLAN_DIGEST_MISMATCH,
|
|
204
|
+
'hook authorization digest does not match the current config, hooks and target version',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return publicState(state);
|
|
208
|
+
}
|
|
209
|
+
state.hookAuthorizedBy = options.actor ?? null;
|
|
210
|
+
state.status = 'NEW';
|
|
211
|
+
state.updatedAt = new Date().toISOString();
|
|
212
|
+
await writeJsonAtomic(statePath, state);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (state.status === 'NEW') {
|
|
216
|
+
const loaded = await deps.loadProjectConfig({ root });
|
|
217
|
+
if (loaded.configDigest !== state.configDigest) {
|
|
218
|
+
const current = hookAuthority(loaded, state.targetVersion);
|
|
219
|
+
state = {
|
|
220
|
+
...state,
|
|
221
|
+
configDigest: loaded.configDigest,
|
|
222
|
+
hooks: current.hooks,
|
|
223
|
+
hookAuthorizationDigest: current.digest,
|
|
224
|
+
status: current.hooks.length > 0 ? 'NEEDS_HOOK_AUTHORIZATION' : 'NEW',
|
|
225
|
+
updatedAt: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
await writeJsonAtomic(statePath, state);
|
|
228
|
+
if (state.status === 'NEEDS_HOOK_AUTHORIZATION') return publicState(state);
|
|
229
|
+
}
|
|
230
|
+
const prepared = await deps.prepareRelease({
|
|
231
|
+
root,
|
|
232
|
+
version: state.targetVersion ?? undefined,
|
|
233
|
+
offline: false,
|
|
234
|
+
production: true,
|
|
235
|
+
hooksAuthorized: true,
|
|
236
|
+
verificationGatesAuthorized: false,
|
|
237
|
+
hookCache: true,
|
|
238
|
+
});
|
|
239
|
+
let transportPreflight = null;
|
|
240
|
+
if (deps.preflightGitTransports) {
|
|
241
|
+
const frozenPlan = JSON.parse(await readFile(prepared.planPath, 'utf8'));
|
|
242
|
+
transportPreflight = await deps.preflightGitTransports(frozenPlan);
|
|
243
|
+
process.env.RELEASE_SKILL_GIT_TRANSPORT = transportPreflight.transport;
|
|
244
|
+
}
|
|
245
|
+
state = {
|
|
246
|
+
...state,
|
|
247
|
+
status: 'NEEDS_PLAN_APPROVAL',
|
|
248
|
+
planPath: prepared.planPath,
|
|
249
|
+
planDigest: prepared.planDigest,
|
|
250
|
+
evidenceDir: prepared.evidenceDir,
|
|
251
|
+
warnings: prepared.warnings,
|
|
252
|
+
...(transportPreflight ? {
|
|
253
|
+
gitTransport: transportPreflight.transport,
|
|
254
|
+
gitTransportPreflight: transportPreflight.repositories,
|
|
255
|
+
} : {}),
|
|
256
|
+
updatedAt: new Date().toISOString(),
|
|
257
|
+
};
|
|
258
|
+
await writeJsonAtomic(statePath, state);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (state.status === 'NEEDS_PLAN_APPROVAL') {
|
|
262
|
+
if (!options.planApprovalDigest) return publicState(state);
|
|
263
|
+
if (options.planApprovalDigest !== state.planDigest) {
|
|
264
|
+
throw new ReleaseError(PLAN_DIGEST_MISMATCH, 'ship plan approval digest does not match the frozen plan');
|
|
265
|
+
}
|
|
266
|
+
if (!options.actor) {
|
|
267
|
+
throw new ReleaseError(GATE_FAILED, 'ship plan approval requires --actor <person>');
|
|
268
|
+
}
|
|
269
|
+
const approval = await deps.approvePlan({
|
|
270
|
+
planPath: state.planPath,
|
|
271
|
+
expectedDigest: state.planDigest,
|
|
272
|
+
actor: options.actor,
|
|
273
|
+
});
|
|
274
|
+
state = {
|
|
275
|
+
...state,
|
|
276
|
+
status: 'APPROVED',
|
|
277
|
+
approvalPath: approval.approvalPath,
|
|
278
|
+
verificationGatesAuthorized: true,
|
|
279
|
+
approvedBy: options.actor,
|
|
280
|
+
updatedAt: new Date().toISOString(),
|
|
281
|
+
};
|
|
282
|
+
await writeJsonAtomic(statePath, state);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (state.status === 'APPROVED' || state.status === 'PUBLISHING') {
|
|
286
|
+
if (!options.adapterRegistry) {
|
|
287
|
+
throw new ReleaseError(GATE_FAILED, 'ship requires an adapter registry to publish');
|
|
288
|
+
}
|
|
289
|
+
state.status = 'PUBLISHING';
|
|
290
|
+
await writeJsonAtomic(statePath, state);
|
|
291
|
+
const published = await deps.publishRelease({
|
|
292
|
+
planPath: state.planPath,
|
|
293
|
+
approvalPath: state.approvalPath,
|
|
294
|
+
adapterRegistry: options.adapterRegistry,
|
|
295
|
+
root,
|
|
296
|
+
productionMode: true,
|
|
297
|
+
productionConfirmation: state.planDigest,
|
|
298
|
+
});
|
|
299
|
+
state = {
|
|
300
|
+
...state,
|
|
301
|
+
status: published.status,
|
|
302
|
+
sourceRunPath: published.runPath,
|
|
303
|
+
updatedAt: new Date().toISOString(),
|
|
304
|
+
};
|
|
305
|
+
await writeJsonAtomic(statePath, state);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (state.status === 'PARTIAL') {
|
|
309
|
+
const reconciled = await deps.reconcileRelease({
|
|
310
|
+
planPath: state.planPath,
|
|
311
|
+
sourceRunPath: state.sourceRunPath,
|
|
312
|
+
approvalPath: state.approvalPath,
|
|
313
|
+
adapterRegistry: options.adapterRegistry,
|
|
314
|
+
root,
|
|
315
|
+
productionConfirmation: state.planDigest,
|
|
316
|
+
});
|
|
317
|
+
state = {
|
|
318
|
+
...state,
|
|
319
|
+
status: reconciled.status,
|
|
320
|
+
sourceRunPath: reconciled.runPath,
|
|
321
|
+
updatedAt: new Date().toISOString(),
|
|
322
|
+
};
|
|
323
|
+
await writeJsonAtomic(statePath, state);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
|
|
327
|
+
try {
|
|
328
|
+
const verified = await deps.verifyRelease({
|
|
329
|
+
planPath: state.planPath,
|
|
330
|
+
sourceRunPath: state.sourceRunPath,
|
|
331
|
+
adapterRegistry: options.adapterRegistry,
|
|
332
|
+
root,
|
|
333
|
+
verificationGatesAuthorized: state.verificationGatesAuthorized === true,
|
|
334
|
+
});
|
|
335
|
+
state = {
|
|
336
|
+
...state,
|
|
337
|
+
status: verified.status,
|
|
338
|
+
verifyRunPath: verified.runPath,
|
|
339
|
+
requirements: undefined,
|
|
340
|
+
updatedAt: new Date().toISOString(),
|
|
341
|
+
};
|
|
342
|
+
await writeJsonAtomic(statePath, state);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error?.code !== CONSUMER_VERIFICATION_DEFERRED) throw error;
|
|
345
|
+
state = {
|
|
346
|
+
...state,
|
|
347
|
+
status: 'NEEDS_MANUAL_ATTESTATIONS',
|
|
348
|
+
requirements: error.details?.requirements ?? [],
|
|
349
|
+
updatedAt: new Date().toISOString(),
|
|
350
|
+
};
|
|
351
|
+
await writeJsonAtomic(statePath, state);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return publicState(state);
|
|
356
|
+
}
|
package/src/commands/verify.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
GATE_FAILED,
|
|
44
44
|
CONFIG_MISSING,
|
|
45
45
|
POST_PUBLISH_VERIFY_FAILED,
|
|
46
|
+
CONSUMER_VERIFICATION_DEFERRED,
|
|
46
47
|
} from '../core/errors.mjs';
|
|
47
48
|
import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
|
|
48
49
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
@@ -67,6 +68,20 @@ import {
|
|
|
67
68
|
buildDirectoryFileIndex,
|
|
68
69
|
checkNpmEntryClosure,
|
|
69
70
|
} from '../npm/npm-entry-closure.mjs';
|
|
71
|
+
import {
|
|
72
|
+
KIMI_ATTESTATION_FILE,
|
|
73
|
+
KIMI_REQUIREMENT_FILE,
|
|
74
|
+
kimiAuthorityDir,
|
|
75
|
+
resolveBoundPlanDigest,
|
|
76
|
+
validateKimiAttestation,
|
|
77
|
+
} from '../platforms/kimi.mjs';
|
|
78
|
+
import {
|
|
79
|
+
CODEBUDDY_ATTESTATION_FILE,
|
|
80
|
+
CODEBUDDY_REQUIREMENT_FILE,
|
|
81
|
+
codebuddyAuthorityDir,
|
|
82
|
+
resolveCodeBuddyBoundPlanDigest,
|
|
83
|
+
validateCodeBuddyAttestation,
|
|
84
|
+
} from '../platforms/codebuddy.mjs';
|
|
70
85
|
|
|
71
86
|
// ---------------------------------------------------------------------------
|
|
72
87
|
// Constants
|
|
@@ -120,6 +135,103 @@ function isValidDigest(digest) {
|
|
|
120
135
|
return typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest);
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Generate every manual consumer requirement before any expensive automatic
|
|
140
|
+
* consumer install starts. This turns a sequence of "install Kimi, rerun,
|
|
141
|
+
* then discover CodeBuddy" failures into one actionable response.
|
|
142
|
+
*/
|
|
143
|
+
async function collectMissingManualAttestations({
|
|
144
|
+
actions,
|
|
145
|
+
adapterRegistry,
|
|
146
|
+
plan,
|
|
147
|
+
root,
|
|
148
|
+
runDir,
|
|
149
|
+
clockFn,
|
|
150
|
+
}) {
|
|
151
|
+
const manual = actions.filter((action) => (
|
|
152
|
+
action.type === 'kimi-marketplace-install'
|
|
153
|
+
|| action.type === 'codebuddy-marketplace-install'
|
|
154
|
+
));
|
|
155
|
+
if (manual.length === 0) return [];
|
|
156
|
+
|
|
157
|
+
const missing = [];
|
|
158
|
+
for (const action of manual) {
|
|
159
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
160
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
161
|
+
// Tests and embedders may intentionally register a fully automatic
|
|
162
|
+
// adapter for these action types. Only the real platform adapter owns the
|
|
163
|
+
// interactive manual-install protocol and its stable authority files.
|
|
164
|
+
if (adapter.name !== 'plugin-marketplace') continue;
|
|
165
|
+
const context = {
|
|
166
|
+
externalWritesAuthorized: false,
|
|
167
|
+
isolatedConsumerWritesAuthorized: true,
|
|
168
|
+
plan,
|
|
169
|
+
baseline: plan.baseline,
|
|
170
|
+
root,
|
|
171
|
+
runDir,
|
|
172
|
+
};
|
|
173
|
+
const actionInput = { actionType: adapterActionType, ...action.parameters };
|
|
174
|
+
const preflight = await adapter.preflight(actionInput, context);
|
|
175
|
+
if (preflight.status !== 'PREFLIGHT_PASSED') {
|
|
176
|
+
throw new ReleaseError(
|
|
177
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
178
|
+
`manual consumer preflight did not pass for action "${action.id}": ${preflight.error}`,
|
|
179
|
+
{ actionId: action.id, platform: action.type.split('-')[0] },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const executed = await adapter.execute(actionInput, context);
|
|
183
|
+
if (executed.status !== 'EXECUTED') {
|
|
184
|
+
throw new ReleaseError(
|
|
185
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
186
|
+
`cannot generate manual consumer requirement for action "${action.id}": ${executed.error}`,
|
|
187
|
+
{ actionId: action.id, platform: action.type.split('-')[0] },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const isKimi = action.type === 'kimi-marketplace-install';
|
|
192
|
+
const planDigest = isKimi
|
|
193
|
+
? resolveBoundPlanDigest(context)
|
|
194
|
+
: await resolveCodeBuddyBoundPlanDigest(context);
|
|
195
|
+
const authorityDir = isKimi
|
|
196
|
+
? kimiAuthorityDir(context, planDigest, action.parameters.plugin)
|
|
197
|
+
: codebuddyAuthorityDir(context, planDigest, action.parameters.plugin);
|
|
198
|
+
const requirementPath = join(
|
|
199
|
+
authorityDir,
|
|
200
|
+
isKimi ? KIMI_REQUIREMENT_FILE : CODEBUDDY_REQUIREMENT_FILE,
|
|
201
|
+
);
|
|
202
|
+
const attestationPath = join(
|
|
203
|
+
authorityDir,
|
|
204
|
+
isKimi ? KIMI_ATTESTATION_FILE : CODEBUDDY_ATTESTATION_FILE,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
let valid = false;
|
|
208
|
+
let reason = 'attestation file is missing';
|
|
209
|
+
try {
|
|
210
|
+
const attestation = JSON.parse(await readFile(attestationPath, 'utf8'));
|
|
211
|
+
const validation = isKimi
|
|
212
|
+
? validateKimiAttestation(attestation, action.parameters, clockFn(), planDigest)
|
|
213
|
+
: validateCodeBuddyAttestation(attestation, action.parameters, clockFn(), planDigest);
|
|
214
|
+
valid = validation.valid;
|
|
215
|
+
reason = validation.error;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error?.code !== 'ENOENT') reason = `attestation cannot be read: ${error.message}`;
|
|
218
|
+
}
|
|
219
|
+
if (!valid) {
|
|
220
|
+
missing.push({
|
|
221
|
+
actionId: action.id,
|
|
222
|
+
platform: isKimi ? 'kimi' : 'codebuddy',
|
|
223
|
+
plugin: action.parameters.plugin,
|
|
224
|
+
version: action.parameters.version,
|
|
225
|
+
planDigest,
|
|
226
|
+
requirementPath,
|
|
227
|
+
attestationPath,
|
|
228
|
+
reason,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return missing;
|
|
233
|
+
}
|
|
234
|
+
|
|
123
235
|
// ---------------------------------------------------------------------------
|
|
124
236
|
// Smoke test
|
|
125
237
|
// ---------------------------------------------------------------------------
|
|
@@ -1000,7 +1112,33 @@ export async function verifyRelease(options) {
|
|
|
1000
1112
|
}
|
|
1001
1113
|
}
|
|
1002
1114
|
|
|
1003
|
-
|
|
1115
|
+
const missingManualAttestations = await collectMissingManualAttestations({
|
|
1116
|
+
actions,
|
|
1117
|
+
adapterRegistry,
|
|
1118
|
+
plan,
|
|
1119
|
+
root,
|
|
1120
|
+
runDir,
|
|
1121
|
+
clockFn,
|
|
1122
|
+
});
|
|
1123
|
+
if (missingManualAttestations.length > 0) {
|
|
1124
|
+
await evidence.append({
|
|
1125
|
+
phase: 'verify',
|
|
1126
|
+
step: 'manual-attestations',
|
|
1127
|
+
status: 'needs-input',
|
|
1128
|
+
requirements: missingManualAttestations,
|
|
1129
|
+
});
|
|
1130
|
+
throw new ReleaseError(
|
|
1131
|
+
CONSUMER_VERIFICATION_DEFERRED,
|
|
1132
|
+
`${missingManualAttestations.length} manual consumer attestation(s) are required`,
|
|
1133
|
+
{ requirements: missingManualAttestations },
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Every action is identity-bound and adapters receive read-only or
|
|
1138
|
+
// per-action isolated consumer paths. Run independent checks concurrently;
|
|
1139
|
+
// the evidence writer serializes append operations and result arrays are
|
|
1140
|
+
// sorted afterwards for deterministic receipts.
|
|
1141
|
+
const actionResults = await Promise.allSettled(actions.map(async (action) => {
|
|
1004
1142
|
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1005
1143
|
|
|
1006
1144
|
// Skip meta-checkpoints
|
|
@@ -1011,7 +1149,7 @@ export async function verifyRelease(options) {
|
|
|
1011
1149
|
status: 'SKIPPED',
|
|
1012
1150
|
reason: 'meta-checkpoint',
|
|
1013
1151
|
});
|
|
1014
|
-
|
|
1152
|
+
return;
|
|
1015
1153
|
}
|
|
1016
1154
|
|
|
1017
1155
|
let adapter;
|
|
@@ -1142,7 +1280,7 @@ export async function verifyRelease(options) {
|
|
|
1142
1280
|
installationContractDigest: currentDigest,
|
|
1143
1281
|
});
|
|
1144
1282
|
|
|
1145
|
-
|
|
1283
|
+
return;
|
|
1146
1284
|
}
|
|
1147
1285
|
}
|
|
1148
1286
|
|
|
@@ -1305,7 +1443,12 @@ export async function verifyRelease(options) {
|
|
|
1305
1443
|
);
|
|
1306
1444
|
}
|
|
1307
1445
|
}
|
|
1308
|
-
}
|
|
1446
|
+
}));
|
|
1447
|
+
adapterChecks.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1448
|
+
consumerVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1449
|
+
consumerGateResults.sort((a, b) => a.id.localeCompare(b.id));
|
|
1450
|
+
const rejectedAction = actionResults.find((result) => result.status === 'rejected');
|
|
1451
|
+
if (rejectedAction) throw rejectedAction.reason;
|
|
1309
1452
|
|
|
1310
1453
|
await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'completed' });
|
|
1311
1454
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
import { ReleaseError, REMOTE_UNAVAILABLE, REMOTE_CONFLICT } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCb);
|
|
7
|
+
|
|
8
|
+
function urls(repo, host) {
|
|
9
|
+
if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
|
|
10
|
+
throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires owner/name repositories');
|
|
11
|
+
}
|
|
12
|
+
const safeHost = host ?? 'github.com';
|
|
13
|
+
if (!/^[A-Za-z0-9.-]+$/.test(safeHost)) {
|
|
14
|
+
throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires a safe GitHub hostname');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
https: `https://${safeHost}/${repo}.git`,
|
|
18
|
+
ssh: `git@${safeHost}:${repo}.git`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function identity(stdout) {
|
|
23
|
+
const lines = String(stdout).trim().split('\n').filter(Boolean);
|
|
24
|
+
const head = lines.find((line) => /^[a-f0-9]{40,64}\s+HEAD$/.test(line.trim()));
|
|
25
|
+
const symref = lines.find((line) => line.startsWith('ref: '));
|
|
26
|
+
return `${symref ?? ''}\n${head ?? ''}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function preflightGitTransports(plan, options = {}) {
|
|
30
|
+
const exec = options.exec ?? ((command, args, execOptions) => execFile(command, args, execOptions));
|
|
31
|
+
const repositories = new Map();
|
|
32
|
+
for (const action of plan.externalActions ?? []) {
|
|
33
|
+
const repo = action.parameters?.repo;
|
|
34
|
+
if (!repo) continue;
|
|
35
|
+
const host = action.parameters?.githubHost ?? 'github.com';
|
|
36
|
+
repositories.set(`${host}/${repo}`, { repo, host });
|
|
37
|
+
}
|
|
38
|
+
const observations = [];
|
|
39
|
+
for (const { repo, host } of repositories.values()) {
|
|
40
|
+
const candidates = urls(repo, host);
|
|
41
|
+
const observation = { repo, host };
|
|
42
|
+
for (const transport of ['https', 'ssh']) {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await exec(
|
|
45
|
+
'git',
|
|
46
|
+
['ls-remote', '--symref', candidates[transport], 'HEAD'],
|
|
47
|
+
{ shell: false, encoding: 'utf8', timeout: 30_000 },
|
|
48
|
+
);
|
|
49
|
+
observation[transport] = {
|
|
50
|
+
status: 'available',
|
|
51
|
+
identity: identity(stdout),
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
observation[transport] = {
|
|
55
|
+
status: 'unavailable',
|
|
56
|
+
error: error.message,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (observation.https.status === 'unavailable' && observation.ssh.status === 'unavailable') {
|
|
61
|
+
throw new ReleaseError(
|
|
62
|
+
REMOTE_UNAVAILABLE,
|
|
63
|
+
`neither HTTPS nor SSH can read ${repo}`,
|
|
64
|
+
{ repository: repo, transports: observation },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
observation.https.status === 'available'
|
|
69
|
+
&& observation.ssh.status === 'available'
|
|
70
|
+
&& observation.https.identity !== observation.ssh.identity
|
|
71
|
+
) {
|
|
72
|
+
throw new ReleaseError(
|
|
73
|
+
REMOTE_CONFLICT,
|
|
74
|
+
`HTTPS and SSH resolve different remote identities for ${repo}`,
|
|
75
|
+
{ repository: repo },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
observations.push(observation);
|
|
79
|
+
}
|
|
80
|
+
const transport = observations.some((entry) => entry.https.status === 'unavailable')
|
|
81
|
+
? 'ssh'
|
|
82
|
+
: 'https';
|
|
83
|
+
if (
|
|
84
|
+
transport === 'ssh'
|
|
85
|
+
&& observations.some((entry) => entry.ssh.status !== 'available')
|
|
86
|
+
) {
|
|
87
|
+
throw new ReleaseError(
|
|
88
|
+
REMOTE_UNAVAILABLE,
|
|
89
|
+
'no single safe Git transport is available for every release repository',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return { transport, repositories: observations };
|
|
93
|
+
}
|
|
@@ -22,11 +22,37 @@ import { canonicalPublicPath } from '../snapshot/public-path.mjs';
|
|
|
22
22
|
|
|
23
23
|
const ROOT_CONTROL_DIRECTORIES = Object.freeze(['.git', '.release-skill']);
|
|
24
24
|
const UNSUPPORTED_GLOB_CHARACTERS = /[!()[\]{}]/u;
|
|
25
|
+
export const PUBLIC_SURFACE_CONFIG_MISSING = 'PUBLIC_SURFACE_CONFIG_MISSING';
|
|
25
26
|
|
|
26
27
|
function compareStrings(left, right) {
|
|
27
28
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Return actionable, non-blocking adoption warnings for release units that
|
|
33
|
+
* have not enabled the expected public-surface gate.
|
|
34
|
+
*
|
|
35
|
+
* This helper never invents project policy or writes configuration. It gives
|
|
36
|
+
* assess and prepare one stable machine code and message while legacy projects
|
|
37
|
+
* remain releasable during the adoption window.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} config
|
|
40
|
+
* @returns {ReadonlyArray<object>}
|
|
41
|
+
*/
|
|
42
|
+
export function collectExpectedPublicSurfaceAdoptionWarnings(config) {
|
|
43
|
+
return Object.freeze(
|
|
44
|
+
(config?.releaseUnits ?? [])
|
|
45
|
+
.filter((unit) => !unit?.expectedPublicSurface)
|
|
46
|
+
.map((unit) => Object.freeze({
|
|
47
|
+
code: PUBLIC_SURFACE_CONFIG_MISSING,
|
|
48
|
+
unitId: unit.id,
|
|
49
|
+
message:
|
|
50
|
+
`发布单元 "${unit.id}" 未配置 expectedPublicSurface;新增或漏配文件不会被分类门禁发现。` +
|
|
51
|
+
'请审阅项目发布边界后,在 .release-skill/project.yaml 中配置 expectedPublicSurface.scanRoots。',
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
function toPosixPath(path) {
|
|
31
57
|
return path.split(sep).join('/');
|
|
32
58
|
}
|