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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +36 -0
  7. package/INSTALL.md +4 -4
  8. package/INSTALL.zh-CN.md +4 -4
  9. package/README.md +38 -13
  10. package/README.zh-CN.md +36 -13
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +3961 -2882
  14. package/adapters/claude/skills/release-help/SKILL.md +9 -1
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +3961 -2882
  17. package/adapters/codex/skills/release-help/SKILL.md +9 -1
  18. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  19. package/adapters/kimi/bin/release-skill.bundle.mjs +3961 -2882
  20. package/adapters/kimi/skills/release-help/SKILL.md +9 -1
  21. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  22. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3961 -2882
  23. package/adapters/workbuddy/skills/release-help/SKILL.md +9 -1
  24. package/bin/release-skill-cli.mjs +180 -4
  25. package/bin/release-skill.bundle.mjs +3961 -2882
  26. package/package.json +1 -1
  27. package/skills/release-help/SKILL.md +9 -1
  28. package/skills-src/release-help/SKILL.md +9 -1
  29. package/src/adapters/plugin-marketplace.mjs +1 -1
  30. package/src/adapters/push-snapshot.mjs +3 -1
  31. package/src/commands/assess.mjs +18 -5
  32. package/src/commands/attest.mjs +195 -0
  33. package/src/commands/hooks.mjs +46 -0
  34. package/src/commands/prepare.mjs +14 -2
  35. package/src/commands/ship.mjs +356 -0
  36. package/src/commands/verify.mjs +147 -4
  37. package/src/core/git-transport.mjs +93 -0
  38. package/src/core/public-surface.mjs +26 -0
  39. 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
+ }
@@ -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
- for (const action of actions) {
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
- continue;
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
- continue;
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
  }