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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|