release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,915 @@
1
+ /**
2
+ * Verify command: post-publish verification and smoke tests.
3
+ *
4
+ * Reads a source run (publish or reconcile) and validates:
5
+ * 1. Source run status is PUBLISHED (VERIFIED is terminal)
6
+ * 2. All checkpoints in the source run are succeeded or skipped
7
+ * 3. Each action's remote state is verified via adapter.verify()
8
+ * 4. Installation smoke test passes
9
+ *
10
+ * The source run is mandatory; verify never silently falls back to plan.status.
11
+ *
12
+ * @module commands/verify
13
+ */
14
+
15
+ import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath } from 'node:fs/promises';
16
+ import { join, relative, isAbsolute, resolve } from 'node:path';
17
+ import { tmpdir } from 'node:os';
18
+ import { execFile as execFileCb } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+
21
+ const execFile = promisify(execFileCb);
22
+
23
+ import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from '../core/plan.mjs';
24
+ import { createEvidenceWriter } from '../core/evidence.mjs';
25
+ import {
26
+ loadRun,
27
+ validateRunPlanDigest,
28
+ validateRunCheckpointMapping,
29
+ validateRunLineage,
30
+ writeRunAtomic,
31
+ computeRunDigest,
32
+ resolveDefaultRunDir,
33
+ } from '../core/run.mjs';
34
+ import {
35
+ assertImmutableApprovalAuthority,
36
+ validateApproval,
37
+ validateApprovalRecordSchema,
38
+ } from '../core/approval.mjs';
39
+ import {
40
+ ReleaseError,
41
+ GATE_FAILED,
42
+ POST_PUBLISH_VERIFY_FAILED,
43
+ } from '../core/errors.mjs';
44
+ import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
45
+ import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
46
+ import {
47
+ normalizeRegistry,
48
+ registryTokenKey,
49
+ resolveNpmRegistryAuthToken,
50
+ } from '../adapters/npm.mjs';
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Constants
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Map plan action type to adapter ActionType.
58
+ * Must match publish.mjs and reconcile.mjs.
59
+ */
60
+ const ADAPTER_ACTION_TYPE_MAP = {
61
+ 'push-commit': 'git-push',
62
+ 'push-snapshot': 'push-snapshot',
63
+ 'create-tag': 'git-tag',
64
+ 'npm-publish': 'npm-publish',
65
+ 'github-release': 'github-release',
66
+ 'claude-marketplace-install': 'claude-marketplace-install',
67
+ 'codex-marketplace-install': 'codex-marketplace-install',
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function defaultClock() {
75
+ return new Date().toISOString();
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Smoke test
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Recursive subset matching: every leaf in `expected` must exist in `actual`
84
+ * with the same value. Nested objects are compared recursively; primitives
85
+ * are compared with strict equality.
86
+ *
87
+ * @param {any} actual
88
+ * @param {any} expected
89
+ * @returns {boolean}
90
+ */
91
+ function matchesSubset(actual, expected) {
92
+ if (expected === null || expected === undefined) {
93
+ return actual === expected;
94
+ }
95
+ if (typeof expected !== 'object' || Array.isArray(expected)) {
96
+ return actual === expected;
97
+ }
98
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
99
+ return false;
100
+ }
101
+ for (const key of Object.keys(expected)) {
102
+ if (!(key in actual) || !matchesSubset(actual[key], expected[key])) {
103
+ return false;
104
+ }
105
+ }
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Run installation smoke test in a temporary directory.
111
+ *
112
+ * For every npm distribution declared in the plan, installs the exact
113
+ * `<package>@<targetVersion>` into an isolated temporary project with
114
+ * safe default npm flags. Validates:
115
+ * - Installed package.json name and version match exactly.
116
+ * - When smokeBin is configured: the specified bin is resolved, validated
117
+ * against path-escape/symlink/non-regular-file guards, and executed with
118
+ * smokeArgs; output is validated against smokeExpectedJson (recursive
119
+ * subset match) when present.
120
+ * - When smokeBin is not configured: install + name/version check passes
121
+ * immediately; runBin is never called; result records
122
+ * cliSmoke: "not-configured".
123
+ * - No best-effort catch: any failure is fail-closed.
124
+ *
125
+ * When no npm distribution exists, returns `{ passed: true, skipped: true }`
126
+ * so pure plugin projects can verify cleanly.
127
+ *
128
+ * @param {Object} plan - The frozen release plan.
129
+ * @param {string} root - Project root for source access.
130
+ * @param {Object} [options]
131
+ * @param {Object} [options.npmExecutor] - Injectable npm executor for testing.
132
+ * @returns {Promise<{ passed: boolean, skipped?: boolean, details: Object }>}
133
+ */
134
+ export async function runSmokeTest(plan, root, options = {}) {
135
+ const baseDir = options.baseDir ?? tmpdir();
136
+ await mkdir(baseDir, { recursive: true });
137
+ const tmpDir = await mkdtemp(join(baseDir, 'verify-smoke-'));
138
+ const npmExec = options.npmExecutor ?? defaultNpmExecutor;
139
+ const installFlags = [
140
+ '--ignore-scripts',
141
+ '--no-audit',
142
+ '--no-fund',
143
+ '--package-lock=false',
144
+ '--save=false',
145
+ ];
146
+
147
+ try {
148
+ // Collect all npm distributions across all units
149
+ const units = plan.units ?? [];
150
+ const npmDistributions = [];
151
+ for (const unit of units) {
152
+ for (const dist of unit.distributions ?? []) {
153
+ if (dist.type === 'npm' && dist.package) {
154
+ npmDistributions.push({
155
+ package: dist.package,
156
+ registry: normalizeRegistry(dist.registry),
157
+ targetVersion: unit.targetVersion,
158
+ unitId: unit.id,
159
+ smokeBin: dist.smokeBin,
160
+ smokeArgs: dist.smokeArgs ?? [],
161
+ smokeExpectedJson: dist.smokeExpectedJson,
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ // No npm distribution: smoke passes with skipped flag
168
+ if (npmDistributions.length === 0) {
169
+ return {
170
+ passed: true,
171
+ skipped: true,
172
+ details: { message: 'No npm distributions in plan; smoke test skipped' },
173
+ };
174
+ }
175
+
176
+ const results = [];
177
+
178
+ for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
179
+ const packageAtVersion = `${pkgName}@${targetVersion}`;
180
+ const installDir = resolveUnitScopedPath(tmpDir, unitId);
181
+ await mkdir(join(installDir, 'node_modules'), { recursive: true });
182
+
183
+ // Install exact package@version with safe flags
184
+ const registryFlags = [...installFlags, '--registry', registry];
185
+ const installResult = await npmExec.install(
186
+ packageAtVersion,
187
+ installDir,
188
+ registryFlags,
189
+ { registry },
190
+ );
191
+ if (!installResult.success) {
192
+ return {
193
+ passed: false,
194
+ details: {
195
+ error: `npm install ${packageAtVersion} failed: ${installResult.error}`,
196
+ packageAtVersion,
197
+ unitId,
198
+ },
199
+ };
200
+ }
201
+
202
+ // Verify installed package.json name and version
203
+ const installedPkgPath = join(installDir, 'node_modules', pkgName, 'package.json');
204
+ let installedPkg;
205
+ try {
206
+ installedPkg = JSON.parse(await readFile(installedPkgPath, 'utf8'));
207
+ } catch {
208
+ return {
209
+ passed: false,
210
+ details: {
211
+ error: `Installed package.json not found at ${installedPkgPath}`,
212
+ packageAtVersion,
213
+ unitId,
214
+ },
215
+ };
216
+ }
217
+
218
+ if (installedPkg.name !== pkgName) {
219
+ return {
220
+ passed: false,
221
+ details: {
222
+ error: `Installed package name mismatch: expected ${pkgName}, got ${installedPkg.name}`,
223
+ packageAtVersion,
224
+ unitId,
225
+ },
226
+ };
227
+ }
228
+
229
+ if (installedPkg.version !== targetVersion) {
230
+ return {
231
+ passed: false,
232
+ details: {
233
+ error: `Installed version mismatch: expected ${targetVersion}, got ${installedPkg.version}`,
234
+ packageAtVersion,
235
+ unitId,
236
+ },
237
+ };
238
+ }
239
+
240
+ // If smokeBin is not configured, install + name/version check is sufficient
241
+ if (!smokeBin) {
242
+ results.push({
243
+ packageName: pkgName,
244
+ version: targetVersion,
245
+ packageAtVersion,
246
+ unitId,
247
+ cliSmoke: 'not-configured',
248
+ });
249
+ continue;
250
+ }
251
+
252
+ // Resolve and validate the specified bin by name
253
+ const binMapping = installedPkg.bin;
254
+ if (!binMapping) {
255
+ return {
256
+ passed: false,
257
+ details: {
258
+ error: `Installed package ${packageAtVersion} has no bin field; smokeBin "${smokeBin}" requested`,
259
+ packageAtVersion,
260
+ unitId,
261
+ },
262
+ };
263
+ }
264
+
265
+ const binRelative = typeof binMapping === 'string'
266
+ ? binMapping
267
+ : binMapping[smokeBin];
268
+ if (typeof binRelative !== 'string' || binRelative.length === 0) {
269
+ return {
270
+ passed: false,
271
+ details: {
272
+ error: `Installed package ${packageAtVersion} does not expose bin "${smokeBin}"`,
273
+ packageAtVersion,
274
+ unitId,
275
+ },
276
+ };
277
+ }
278
+
279
+ // Verify bin path does not escape the installed package root
280
+ const pkgRoot = join(installDir, 'node_modules', pkgName);
281
+ const binPath = resolve(pkgRoot, binRelative);
282
+ const relBin = relative(pkgRoot, binPath);
283
+ const sep = process.platform === 'win32' ? '\\' : '/';
284
+ if (isAbsolute(relBin) || relBin === '..' || relBin.startsWith(`..${sep}`)) {
285
+ return {
286
+ passed: false,
287
+ details: {
288
+ error: `Bin path escapes package root: ${binPath}`,
289
+ packageAtVersion,
290
+ unitId,
291
+ },
292
+ };
293
+ }
294
+
295
+ let binStat;
296
+ try {
297
+ binStat = await lstat(binPath);
298
+ const [pkgRootReal, binPathReal] = await Promise.all([realpath(pkgRoot), realpath(binPath)]);
299
+ const relReal = relative(pkgRootReal, binPathReal);
300
+ if (
301
+ !binStat.isFile() ||
302
+ binStat.isSymbolicLink() ||
303
+ isAbsolute(relReal) ||
304
+ relReal === '..' ||
305
+ relReal.startsWith(`..${sep}`)
306
+ ) {
307
+ throw new Error('bin is not a regular file inside the installed package');
308
+ }
309
+ } catch (err) {
310
+ return {
311
+ passed: false,
312
+ details: {
313
+ error: `Invalid installed bin for ${packageAtVersion}: ${err.message}`,
314
+ packageAtVersion,
315
+ unitId,
316
+ },
317
+ };
318
+ }
319
+
320
+ // Run CLI smoke — fail-closed, no best-effort catch
321
+ const cliArgs = smokeArgs.length > 0 ? smokeArgs : [];
322
+ let binResult;
323
+ try {
324
+ binResult = await npmExec.runBin(binPath, cliArgs);
325
+ } catch (binErr) {
326
+ return {
327
+ passed: false,
328
+ details: {
329
+ error: `CLI smoke execution failed for ${packageAtVersion}: ${binErr.message}`,
330
+ packageAtVersion,
331
+ unitId,
332
+ },
333
+ };
334
+ }
335
+
336
+ if (binResult.exitCode !== 0 && binResult.exitCode !== undefined) {
337
+ return {
338
+ passed: false,
339
+ details: {
340
+ error: `CLI smoke exited with code ${binResult.exitCode} for ${packageAtVersion}`,
341
+ packageAtVersion,
342
+ unitId,
343
+ },
344
+ };
345
+ }
346
+
347
+ // Validate CLI output
348
+ if (smokeExpectedJson) {
349
+ // Recursive subset matching: all expected fields must be present and equal
350
+ let parsedOutput;
351
+ try {
352
+ parsedOutput = JSON.parse(binResult.stdout);
353
+ } catch {
354
+ return {
355
+ passed: false,
356
+ details: {
357
+ error: `CLI smoke returned non-JSON output for ${packageAtVersion}`,
358
+ packageAtVersion,
359
+ unitId,
360
+ },
361
+ };
362
+ }
363
+ if (!matchesSubset(parsedOutput, smokeExpectedJson)) {
364
+ return {
365
+ passed: false,
366
+ details: {
367
+ error: `CLI smoke JSON output does not match expected fields for ${packageAtVersion}`,
368
+ packageAtVersion,
369
+ unitId,
370
+ expected: smokeExpectedJson,
371
+ actual: parsedOutput,
372
+ },
373
+ };
374
+ }
375
+ } else {
376
+ // No expected JSON specified: only require valid JSON output
377
+ try {
378
+ JSON.parse(binResult.stdout);
379
+ } catch {
380
+ return {
381
+ passed: false,
382
+ details: {
383
+ error: `CLI smoke returned non-JSON output for ${packageAtVersion}`,
384
+ packageAtVersion,
385
+ unitId,
386
+ },
387
+ };
388
+ }
389
+ }
390
+
391
+ results.push({
392
+ packageName: pkgName,
393
+ version: targetVersion,
394
+ packageAtVersion,
395
+ unitId,
396
+ cliSmoke: 'passed',
397
+ });
398
+ }
399
+
400
+ return {
401
+ passed: true,
402
+ details: {
403
+ distributions: results,
404
+ count: results.length,
405
+ },
406
+ };
407
+ } finally {
408
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Default npm executor that runs real npm commands.
414
+ *
415
+ * Install flags include --ignore-scripts, --no-audit, --no-fund,
416
+ * --package-lock=false, --save=false for safe isolated installs.
417
+ */
418
+ const defaultNpmExecutor = {
419
+ async install(packageAtVersion, cwd, flags, { registry }) {
420
+ const normalizedRegistry = normalizeRegistry(registry);
421
+ const token = await resolveNpmRegistryAuthToken({
422
+ registry: normalizedRegistry,
423
+ cwd,
424
+ exec: execFile,
425
+ env: process.env,
426
+ });
427
+ const userConfig = join(cwd, '.release-skill-npmrc');
428
+ await writeFile(
429
+ userConfig,
430
+ `registry=${normalizedRegistry}/\n${registryTokenKey(normalizedRegistry)}=${token}\n`,
431
+ { encoding: 'utf8', mode: 0o600 },
432
+ );
433
+ const env = { ...process.env };
434
+ for (const name of [
435
+ 'NPM_TOKEN', 'NODE_AUTH_TOKEN',
436
+ 'NPM_CONFIG_REGISTRY', 'npm_config_registry',
437
+ 'NPM_CONFIG_USERCONFIG', 'npm_config_userconfig',
438
+ ]) delete env[name];
439
+ try {
440
+ await execFile('npm', [
441
+ 'install', packageAtVersion,
442
+ ...flags,
443
+ '--userconfig', userConfig,
444
+ ], {
445
+ cwd,
446
+ env,
447
+ shell: false,
448
+ encoding: 'utf8',
449
+ timeout: 60_000,
450
+ });
451
+ return { success: true };
452
+ } catch (err) {
453
+ return { success: false, error: err.message };
454
+ }
455
+ },
456
+ async runBin(binPath, args = []) {
457
+ return execFile(process.execPath, [binPath, ...args], {
458
+ shell: false,
459
+ encoding: 'utf8',
460
+ timeout: 30_000,
461
+ });
462
+ },
463
+ };
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Public API
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /**
470
+ * Post-publish verification of a release.
471
+ *
472
+ * @param {Object} options
473
+ * @param {string} options.planPath - Absolute path to the frozen release plan.
474
+ * @param {string} options.sourceRunPath - Absolute path to the source run.
475
+ * @param {Object} options.adapterRegistry - Adapter registry for verification.
476
+ * @param {string} [options.root] - Project root for source access.
477
+ * @param {string} [options.runDir] - Evidence directory.
478
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
479
+ *
480
+ * @returns {Promise<{ planPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
481
+ *
482
+ * @throws {ReleaseError} GATE_FAILED on safety gate failures.
483
+ * @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED if any verification fails.
484
+ */
485
+ export async function verifyRelease(options) {
486
+ const {
487
+ planPath,
488
+ sourceRunPath,
489
+ adapterRegistry,
490
+ root = process.cwd(),
491
+ runDir: runDirOpt,
492
+ clock: clockOpt,
493
+ npmExecutor,
494
+ } = options ?? {};
495
+
496
+ const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
497
+
498
+ // --- Gate: sourceRunPath is required ---
499
+ if (!sourceRunPath) {
500
+ throw new ReleaseError(
501
+ GATE_FAILED,
502
+ 'verify requires a source run path (--run)',
503
+ { parameter: 'sourceRunPath' },
504
+ );
505
+ }
506
+
507
+ // --- Set up directories ---
508
+ const runId = `verify-${Date.now()}`;
509
+ const runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
510
+ await mkdir(runDir, { recursive: true });
511
+
512
+ const evidence = createEvidenceWriter({ runDir, command: 'verify', clock: clockFn });
513
+
514
+ try {
515
+ // =======================================================================
516
+ // Step 1: Load and validate release plan
517
+ // =======================================================================
518
+ await evidence.append({ phase: 'verify', step: 'plan-load', status: 'started' });
519
+
520
+ let planRaw;
521
+ try {
522
+ planRaw = await readFile(planPath, 'utf8');
523
+ } catch (err) {
524
+ throw new ReleaseError(
525
+ GATE_FAILED,
526
+ `cannot read release plan: ${err.message}`,
527
+ { planPath, cause: err.code },
528
+ );
529
+ }
530
+
531
+ let plan;
532
+ try {
533
+ plan = JSON.parse(planRaw);
534
+ } catch (err) {
535
+ throw new ReleaseError(
536
+ GATE_FAILED,
537
+ `release plan is not valid JSON: ${err.message}`,
538
+ { planPath },
539
+ );
540
+ }
541
+
542
+ validatePlan(plan);
543
+
544
+ await evidence.append({ phase: 'verify', step: 'plan-load', status: 'passed' });
545
+
546
+ // =======================================================================
547
+ // Step 2: Load and validate source run
548
+ // =======================================================================
549
+ await evidence.append({ phase: 'verify', step: 'source-run-load', status: 'started' });
550
+
551
+ const sourceRun = await loadRun(sourceRunPath, {
552
+ requireDigest: Boolean(plan.production),
553
+ ...(plan.production ? { authorityPlanPath: planPath } : {}),
554
+ });
555
+ await validateRunLineage(sourceRun, {
556
+ plan,
557
+ planPath,
558
+ runPath: sourceRunPath,
559
+ production: Boolean(plan.production),
560
+ });
561
+
562
+ // Only accept source runs from publish or reconcile commands
563
+ if (sourceRun.command !== 'publish' && sourceRun.command !== 'reconcile') {
564
+ throw new ReleaseError(
565
+ GATE_FAILED,
566
+ `verify only accepts source runs from publish or reconcile; source run command is "${sourceRun.command}"`,
567
+ { sourceRunCommand: sourceRun.command, sourceRunId: sourceRun.runId },
568
+ );
569
+ }
570
+
571
+ // VERIFIED is terminal: verification may only promote PUBLISHED once.
572
+ if (sourceRun.status !== 'PUBLISHED') {
573
+ throw new ReleaseError(
574
+ GATE_FAILED,
575
+ `cannot verify: source run status is "${sourceRun.status}"; expected PUBLISHED (VERIFIED is terminal)`,
576
+ { sourceRunStatus: sourceRun.status },
577
+ );
578
+ }
579
+
580
+ if (plan.production) {
581
+ if (!sourceRun.approvalPath || !sourceRun.approvalDigest) {
582
+ throw new ReleaseError(
583
+ GATE_FAILED,
584
+ 'production verify requires immutable approvalPath and approvalDigest on the source run',
585
+ );
586
+ }
587
+ let approvalRaw;
588
+ try {
589
+ approvalRaw = await readFile(sourceRun.approvalPath, 'utf8');
590
+ } catch (error) {
591
+ throw new ReleaseError(
592
+ GATE_FAILED,
593
+ `cannot read source run approval authority: ${error.message}`,
594
+ );
595
+ }
596
+ let approval;
597
+ try {
598
+ approval = JSON.parse(approvalRaw);
599
+ } catch (error) {
600
+ throw new ReleaseError(GATE_FAILED, `source run approval is not valid JSON: ${error.message}`);
601
+ }
602
+ validateApprovalRecordSchema(approval);
603
+ const approvalDigest = assertImmutableApprovalAuthority(
604
+ sourceRun.approvalPath,
605
+ plan,
606
+ approvalRaw,
607
+ );
608
+ if (approvalDigest !== sourceRun.approvalDigest) {
609
+ throw new ReleaseError(
610
+ GATE_FAILED,
611
+ 'source run approvalDigest does not match immutable approval bytes',
612
+ );
613
+ }
614
+ validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
615
+ }
616
+
617
+ // Validate plan action completeness before checkpoint mapping
618
+ const completenessResult = validatePlanActionCompleteness(plan);
619
+ if (!completenessResult.passed) {
620
+ throw new ReleaseError(
621
+ GATE_FAILED,
622
+ `plan action completeness gate failed: ${completenessResult.details.failures.join('; ')}`,
623
+ { failures: completenessResult.details.failures },
624
+ );
625
+ }
626
+
627
+ // Validate checkpoint mapping
628
+ validateRunCheckpointMapping(sourceRun, plan.externalActions ?? []);
629
+
630
+ // All checkpoints must be succeeded or skipped (no failed/pending)
631
+ const incompleteCheckpoints = sourceRun.checkpoints.filter(
632
+ (cp) => cp.status !== 'succeeded' && cp.status !== 'skipped',
633
+ );
634
+ if (incompleteCheckpoints.length > 0) {
635
+ throw new ReleaseError(
636
+ GATE_FAILED,
637
+ `cannot verify: source run has ${incompleteCheckpoints.length} incomplete checkpoint(s): ${incompleteCheckpoints.map((cp) => `${cp.actionId}=${cp.status}`).join(', ')}`,
638
+ { incompleteCheckpoints: incompleteCheckpoints.map((cp) => ({ actionId: cp.actionId, status: cp.status })) },
639
+ );
640
+ }
641
+
642
+ await evidence.append({
643
+ phase: 'verify',
644
+ step: 'source-run-load',
645
+ status: 'passed',
646
+ sourceRunId: sourceRun.runId,
647
+ });
648
+
649
+ // =======================================================================
650
+ // Step 3: Verify all actions via adapters
651
+ //
652
+ // Marketplace actions (claude-marketplace-install, codex-marketplace-install)
653
+ // are verified as fresh, isolated consumer installs in verify's own runDir.
654
+ // This ensures verify does not read the publish run's consumer install
655
+ // directories or evidence.
656
+ //
657
+ // Non-marketplace actions use read-only adapter.verify().
658
+ // =======================================================================
659
+ await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'started' });
660
+
661
+ const adapterChecks = [];
662
+ const actions = plan.externalActions ?? [];
663
+ const MARKETPLACE_TYPES = new Set([
664
+ 'claude-marketplace-install',
665
+ 'codex-marketplace-install',
666
+ ]);
667
+
668
+ for (const action of actions) {
669
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
670
+
671
+ // Skip meta-checkpoints
672
+ if (!adapterActionType) {
673
+ adapterChecks.push({
674
+ actionId: action.id,
675
+ actionType: action.type,
676
+ status: 'SKIPPED',
677
+ reason: 'meta-checkpoint',
678
+ });
679
+ continue;
680
+ }
681
+
682
+ let adapter;
683
+ try {
684
+ adapter = adapterRegistry.getAdapter(adapterActionType);
685
+ } catch {
686
+ // Missing adapter for a verified action => structured failure (not silent SKIPPED)
687
+ throw new ReleaseError(
688
+ POST_PUBLISH_VERIFY_FAILED,
689
+ `no adapter registered for action type "${adapterActionType}" (plan action "${action.id}")`,
690
+ { actionId: action.id, adapterActionType },
691
+ );
692
+ }
693
+
694
+ if (MARKETPLACE_TYPES.has(action.type)) {
695
+ // --- Marketplace: fresh consumer verification in verify's own runDir ---
696
+ // Context: isolatedConsumerWritesAuthorized allows writing to verify's
697
+ // runDir/consumers/ directory; externalWritesAuthorized stays false.
698
+ const marketplaceContext = {
699
+ externalWritesAuthorized: false,
700
+ isolatedConsumerWritesAuthorized: true,
701
+ plan,
702
+ baseline: plan.baseline,
703
+ root,
704
+ runDir,
705
+ };
706
+
707
+ const actionInput = {
708
+ actionType: adapterActionType,
709
+ ...action.parameters,
710
+ };
711
+
712
+ // Step 3a: Preflight (validate frozen snapshot, parameters)
713
+ const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
714
+ if (preflightResult.status === 'PREFLIGHT_FAILED') {
715
+ adapterChecks.push({
716
+ actionId: action.id,
717
+ actionType: action.type,
718
+ status: 'FAILED',
719
+ error: `preflight failed: ${preflightResult.error}`,
720
+ });
721
+ throw new ReleaseError(
722
+ POST_PUBLISH_VERIFY_FAILED,
723
+ `marketplace preflight failed for action "${action.id}": ${preflightResult.error}`,
724
+ { actionId: action.id },
725
+ );
726
+ }
727
+
728
+ // Step 3b: Execute (install to isolated consumer directory)
729
+ const executeResult = await adapter.execute(actionInput, marketplaceContext);
730
+ if (executeResult.status !== 'EXECUTED') {
731
+ adapterChecks.push({
732
+ actionId: action.id,
733
+ actionType: action.type,
734
+ status: 'FAILED',
735
+ error: `execute failed: ${executeResult.error}`,
736
+ });
737
+ throw new ReleaseError(
738
+ POST_PUBLISH_VERIFY_FAILED,
739
+ `marketplace execute failed for action "${action.id}": ${executeResult.error}`,
740
+ { actionId: action.id },
741
+ );
742
+ }
743
+
744
+ // Step 3c: Verify (observe + match against plan expected state)
745
+ const verifyResult = await adapter.verify(
746
+ { ...actionInput, expected: action.expected },
747
+ marketplaceContext,
748
+ );
749
+
750
+ const check = {
751
+ actionId: action.id,
752
+ actionType: action.type,
753
+ status: verifyResult.status === 'VERIFIED' ? 'PASSED' : 'FAILED',
754
+ observation: verifyResult.observation,
755
+ error: verifyResult.error,
756
+ };
757
+ adapterChecks.push(check);
758
+
759
+ await evidence.append({
760
+ phase: 'verify-marketplace',
761
+ actionId: action.id,
762
+ actionType: action.type,
763
+ status: check.status,
764
+ });
765
+
766
+ if (check.status === 'FAILED') {
767
+ throw new ReleaseError(
768
+ POST_PUBLISH_VERIFY_FAILED,
769
+ `marketplace verification failed for action "${action.id}": ${verifyResult.error}`,
770
+ { actionId: action.id, observation: verifyResult.observation },
771
+ );
772
+ }
773
+ } else {
774
+ // --- Non-marketplace: read-only adapter.verify() ---
775
+ const context = {
776
+ externalWritesAuthorized: false,
777
+ plan,
778
+ baseline: plan.baseline,
779
+ root,
780
+ runDir,
781
+ };
782
+
783
+ const verifyResult = await adapter.verify(
784
+ {
785
+ actionType: adapterActionType,
786
+ ...action.parameters,
787
+ expected: action.expected,
788
+ },
789
+ context,
790
+ );
791
+
792
+ const check = {
793
+ actionId: action.id,
794
+ actionType: action.type,
795
+ status: verifyResult.status === 'VERIFIED' ? 'PASSED' : 'FAILED',
796
+ observation: verifyResult.observation,
797
+ error: verifyResult.error,
798
+ };
799
+
800
+ adapterChecks.push(check);
801
+
802
+ await evidence.append({
803
+ phase: 'verify-adapter',
804
+ actionId: action.id,
805
+ actionType: action.type,
806
+ status: check.status,
807
+ });
808
+
809
+ if (check.status === 'FAILED') {
810
+ throw new ReleaseError(
811
+ POST_PUBLISH_VERIFY_FAILED,
812
+ `adapter verification failed for action "${action.id}": ${verifyResult.error}`,
813
+ { actionId: action.id, observation: verifyResult.observation },
814
+ );
815
+ }
816
+ }
817
+ }
818
+
819
+ await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'completed' });
820
+
821
+ // =======================================================================
822
+ // Step 4: Installation smoke test
823
+ // =======================================================================
824
+ await evidence.append({ phase: 'verify', step: 'smoke-test', status: 'started' });
825
+
826
+ let smokeTest;
827
+ try {
828
+ smokeTest = await runSmokeTest(plan, root, { npmExecutor, baseDir: runDir });
829
+ } catch (err) {
830
+ smokeTest = { passed: false, details: { error: err.message } };
831
+ }
832
+
833
+ await evidence.append({
834
+ phase: 'verify',
835
+ step: 'smoke-test',
836
+ status: smokeTest.passed ? 'passed' : 'failed',
837
+ details: smokeTest.details,
838
+ });
839
+
840
+ if (!smokeTest.passed) {
841
+ throw new ReleaseError(
842
+ POST_PUBLISH_VERIFY_FAILED,
843
+ `installation smoke test failed: ${smokeTest.details.error}`,
844
+ { smokeTest: smokeTest.details },
845
+ );
846
+ }
847
+
848
+ // =======================================================================
849
+ // All verifications passed — write verify run
850
+ // =======================================================================
851
+ assertTransition(PUBLISHED, VERIFIED);
852
+ await evidence.append({ phase: 'verify', status: 'completed', overallStatus: VERIFIED });
853
+
854
+ const sourceRunDigest = sourceRun.runDigest ?? computeRunDigest(sourceRun);
855
+
856
+ const verifyRunPath = join(runDir, 'release-run.json');
857
+ const verifyRunState = {
858
+ runId,
859
+ command: 'verify',
860
+ planDigest: plan.digest,
861
+ planPath,
862
+ ...(sourceRun.approvalPath ? {
863
+ approvalPath: sourceRun.approvalPath,
864
+ approvalDigest: sourceRun.approvalDigest,
865
+ } : {}),
866
+ sourceRunId: sourceRun.runId,
867
+ sourceRunDigest,
868
+ sourceRunPath,
869
+ status: VERIFIED,
870
+ checkpoints: actions.map((a) => {
871
+ const check = adapterChecks.find((c) => c.actionId === a.id);
872
+ return {
873
+ actionId: a.id,
874
+ actionType: a.type,
875
+ status: check?.status === 'SKIPPED' ? 'skipped' : 'succeeded',
876
+ };
877
+ }),
878
+ startedAt: clockFn(),
879
+ finishedAt: clockFn(),
880
+ };
881
+ const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
882
+
883
+ await evidence.finish({
884
+ status: VERIFIED,
885
+ planPath,
886
+ sourceRunId: sourceRun.runId,
887
+ sourceRunDigest,
888
+ runDigest: persistedVerifyRun.runDigest,
889
+ adapterCheckCount: adapterChecks.length,
890
+ smokeTestPassed: true,
891
+ completedAt: clockFn(),
892
+ });
893
+
894
+ return {
895
+ planPath,
896
+ status: VERIFIED,
897
+ adapterChecks,
898
+ smokeTest,
899
+ };
900
+ } catch (err) {
901
+ await evidence.append({
902
+ phase: 'verify',
903
+ status: 'failed',
904
+ error: { code: err.code, message: err.message },
905
+ });
906
+
907
+ await evidence.finish({
908
+ status: 'FAILED',
909
+ error: { code: err.code, message: err.message },
910
+ failedAt: clockFn(),
911
+ });
912
+
913
+ throw err;
914
+ }
915
+ }