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,1377 @@
1
+ /**
2
+ * Prepare command: freeze a release plan with snapshots and gates.
3
+ *
4
+ * Runs the full prepare pipeline in order:
5
+ * 1. Load and validate project configuration
6
+ * 2. Capture Git baseline (HEAD, tree hash, dirty files)
7
+ * 3. Run project-declared hooks (build, test)
8
+ * 4. For each release unit: build snapshot, scan for leakage, evaluate README
9
+ * 5. Check remote tag / version uniqueness (skipped in --offline mode)
10
+ * 6. Assemble and validate the release plan against the plan schema
11
+ * 7. Write the plan atomically
12
+ *
13
+ * If any gate fails, no PREPARED plan is written.
14
+ *
15
+ * @module commands/prepare
16
+ */
17
+
18
+ import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
19
+ import { readFile, mkdir, realpath } from 'node:fs/promises';
20
+ import { execFile as execFileCb } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+
23
+ const execFile = promisify(execFileCb);
24
+
25
+ import { loadProjectConfig } from '../core/config.mjs';
26
+ import { captureBaseline } from '../core/baseline.mjs';
27
+ import { runHook } from '../core/hooks.mjs';
28
+ import { createEvidenceWriter } from '../core/evidence.mjs';
29
+ import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
30
+ import { sha256Hex } from '../core/digest.mjs';
31
+ import { buildPublicStaging } from '../snapshot/public-map.mjs';
32
+ import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
33
+ import { scanSnapshot } from '../snapshot/scan.mjs';
34
+ import { evaluateReadme } from '../readme/contract.mjs';
35
+ import {
36
+ buildFrozenGitRepository,
37
+ buildFrozenNpmTarball,
38
+ computeFrozenSnapshot,
39
+ sealFrozenSnapshot,
40
+ } from '../snapshot/frozen.mjs';
41
+ import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
42
+ import { acquireProjectLock } from '../artifacts/project-lock.mjs';
43
+ import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
44
+ import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
45
+ import { createProductionPrepareRunDir } from '../core/run.mjs';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Version resolution
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Resolve the target version for a release unit.
53
+ *
54
+ * Resolution rules:
55
+ * 1. If explicitVersion is provided, use it (overrides everything).
56
+ * 2. Otherwise, read from `<root>/<unit.source>/<unit.version.source>`.
57
+ * 3. Reject: absolute path, path escape, missing file, invalid JSON,
58
+ * missing/empty version field.
59
+ * 4. For v0.1: if multiple units resolve to different versions, fail closed.
60
+ *
61
+ * @param {object} unit - The release unit configuration.
62
+ * @param {string} root - Absolute project root.
63
+ * @param {string} [explicitVersion] - Explicit version override.
64
+ * @returns {Promise<string>} The resolved version string.
65
+ * @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
66
+ */
67
+ async function resolveUnitVersion(unit, root, explicitVersion) {
68
+ // Validate unit.version.source exists
69
+ const versionSource = unit.version?.source;
70
+ if (!versionSource || typeof versionSource !== 'string') {
71
+ throw new ReleaseError(
72
+ CONFIG_INVALID,
73
+ `unit "${unit.id}" missing version.source configuration`,
74
+ { unitId: unit.id },
75
+ );
76
+ }
77
+
78
+ // Reject absolute paths
79
+ if (isAbsolute(versionSource)) {
80
+ throw new ReleaseError(
81
+ CONFIG_INVALID,
82
+ `unit "${unit.id}" version.source must be a relative path, got absolute: "${versionSource}"`,
83
+ { unitId: unit.id, versionSource },
84
+ );
85
+ }
86
+
87
+ // Resolve and normalize the path
88
+ const unitRoot = resolve(root, unit.source);
89
+ const resolvedPath = resolve(unitRoot, versionSource);
90
+ const normalizedPath = normalize(resolvedPath);
91
+
92
+ // Reject path escapes (must stay within unit root)
93
+ const rel = relative(unitRoot, normalizedPath);
94
+ if (rel.startsWith('..') || rel === '..' || isAbsolute(rel)) {
95
+ throw new ReleaseError(
96
+ CONFIG_INVALID,
97
+ `unit "${unit.id}" version.source escapes unit root: "${versionSource}"`,
98
+ { unitId: unit.id, versionSource, resolved: normalizedPath },
99
+ );
100
+ }
101
+
102
+ // Read the file
103
+ let content;
104
+ try {
105
+ content = await readFile(normalizedPath, 'utf8');
106
+ } catch (err) {
107
+ throw new ReleaseError(
108
+ CONFIG_INVALID,
109
+ `unit "${unit.id}" cannot read version file "${versionSource}": ${err.message}`,
110
+ { unitId: unit.id, versionSource, cause: err.code },
111
+ );
112
+ }
113
+
114
+ // Parse JSON
115
+ let pkg;
116
+ try {
117
+ pkg = JSON.parse(content);
118
+ } catch (err) {
119
+ throw new ReleaseError(
120
+ CONFIG_INVALID,
121
+ `unit "${unit.id}" invalid JSON in "${versionSource}": ${err.message}`,
122
+ { unitId: unit.id, versionSource },
123
+ );
124
+ }
125
+
126
+ // Extract version field
127
+ const version = pkg?.version;
128
+ if (!version || typeof version !== 'string' || version.trim().length === 0) {
129
+ throw new ReleaseError(
130
+ CONFIG_INVALID,
131
+ `unit "${unit.id}" missing or empty version field in "${versionSource}"`,
132
+ { unitId: unit.id, versionSource, found: version },
133
+ );
134
+ }
135
+
136
+ const authoritativeVersion = version.trim();
137
+ if (explicitVersion && explicitVersion !== authoritativeVersion) {
138
+ throw new ReleaseError(
139
+ GATE_FAILED,
140
+ `unit "${unit.id}" explicit version "${explicitVersion}" does not match authoritative version "${authoritativeVersion}" from "${versionSource}"`,
141
+ { unitId: unit.id, explicitVersion, authoritativeVersion, versionSource },
142
+ );
143
+ }
144
+
145
+ return authoritativeVersion;
146
+ }
147
+
148
+ /**
149
+ * Resolve versions for all release units independently.
150
+ *
151
+ * @param {object[]} units - Array of release unit configurations.
152
+ * @param {string} root - Absolute project root.
153
+ * @param {string} [explicitVersion] - Explicit version override.
154
+ * @param {object} evidence - The evidence writer.
155
+ * @returns {Promise<string[]>} Array of resolved versions (one per unit).
156
+ * @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
157
+ */
158
+ async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
159
+ await evidence.append({ phase: 'version-resolution', status: 'started' });
160
+
161
+ const resolvedVersions = [];
162
+ for (const unit of units) {
163
+ try {
164
+ const version = await resolveUnitVersion(unit, root, explicitVersion);
165
+ resolvedVersions.push(version);
166
+
167
+ } catch (err) {
168
+ await evidence.append({
169
+ phase: 'version-resolution',
170
+ status: 'failed',
171
+ unitId: unit.id,
172
+ error: { code: err.code, message: err.message },
173
+ });
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ await evidence.append({
179
+ phase: 'version-resolution',
180
+ status: 'completed',
181
+ unitCount: units.length,
182
+ resolvedVersions: Object.fromEntries(units.map((unit, index) => [unit.id, resolvedVersions[index]])),
183
+ explicitVersion: !!explicitVersion,
184
+ });
185
+
186
+ return resolvedVersions;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Hooks execution
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Run all declared project hooks in order: docs, build, test, typecheck.
195
+ *
196
+ * @param {object} config - The loaded project config.
197
+ * @param {string} root - Absolute project root.
198
+ * @param {object} evidence - The evidence writer.
199
+ * @returns {Promise<void>}
200
+ * @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code.
201
+ */
202
+ async function runDeclaredHooks(config, root, evidence) {
203
+ const hookOrder = ['docs', 'build', 'test', 'typecheck'];
204
+ const hooks = config.hooks ?? {};
205
+
206
+ for (const name of hookOrder) {
207
+ const hook = hooks[name];
208
+ if (!hook) continue;
209
+
210
+ await evidence.append({
211
+ phase: 'hooks',
212
+ status: 'started',
213
+ hookName: name,
214
+ });
215
+
216
+ let result;
217
+ try {
218
+ result = await runHook(hook, { root });
219
+ } catch (err) {
220
+ await evidence.append({
221
+ phase: 'hooks',
222
+ status: 'failed',
223
+ hookName: name,
224
+ error: { code: err.code, message: err.message },
225
+ });
226
+ throw new ReleaseError(
227
+ GATE_FAILED,
228
+ `hook "${name}" failed: ${err.message}`,
229
+ { hookName: name, cause: err.code },
230
+ );
231
+ }
232
+
233
+ if (result.exitCode !== 0) {
234
+ await evidence.append({
235
+ phase: 'hooks',
236
+ status: 'failed',
237
+ hookName: name,
238
+ exitCode: result.exitCode,
239
+ // Test runners usually emit the actionable failure summary at the
240
+ // end. Preserve bounded tails of both streams instead of the noisy
241
+ // compiler prelude at the beginning.
242
+ stdoutTail: result.stdout.slice(-4000),
243
+ stderrTail: result.stderr.slice(-4000),
244
+ });
245
+ throw new ReleaseError(
246
+ GATE_FAILED,
247
+ `hook "${name}" exited with code ${result.exitCode}`,
248
+ { hookName: name, exitCode: result.exitCode },
249
+ );
250
+ }
251
+
252
+ await evidence.append({
253
+ phase: 'hooks',
254
+ status: 'completed',
255
+ hookName: name,
256
+ exitCode: 0,
257
+ });
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Snapshot pipeline
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Build snapshots for all release units, scan for leakage, and evaluate README.
267
+ *
268
+ * @param {object} config - The loaded project config.
269
+ * @param {string} root - Absolute project root.
270
+ * @param {object} evidence - The evidence writer.
271
+ * @param {string} runDir - The run directory for temp snapshot storage.
272
+ * @returns {Promise<{ unitResults: object[], snapshotDigests: string[] }>}
273
+ * @throws {ReleaseError} GATE_FAILED on any snapshot/scan/readme gate failure.
274
+ */
275
+ async function processSnapshots(config, root, evidence, runDir, production = false) {
276
+ const units = config.releaseUnits ?? [];
277
+ const unitResults = [];
278
+ const snapshotDigests = [];
279
+
280
+ for (const unit of units) {
281
+ const outputDir = resolveUnitScopedPath(resolve(runDir, 'snapshots'), unit.id);
282
+
283
+ // --- Build snapshot ---
284
+ await evidence.append({
285
+ phase: 'snapshot',
286
+ status: 'started',
287
+ unitId: unit.id,
288
+ source: unit.source,
289
+ });
290
+
291
+ let manifest;
292
+ try {
293
+ // All units use explicit public file mappings — no implicit
294
+ // git/package.json collection.
295
+ const publicManifest = await buildPublicStaging({
296
+ sourceRoot: root,
297
+ unit,
298
+ outputDir,
299
+ });
300
+ // Adapt the public manifest to the shape expected by downstream code.
301
+ manifest = {
302
+ entries: publicManifest.entries,
303
+ files: publicManifest.entries.map((e) => e.path).sort(),
304
+ totalSize: publicManifest.totalSize,
305
+ fileCount: publicManifest.fileCount,
306
+ contentHash: publicManifest.contentHash,
307
+ snapshotDigest: publicManifest.contentHash,
308
+ source: unit.source,
309
+ outputDir: publicManifest.outputDir,
310
+ };
311
+ } catch (err) {
312
+ await evidence.append({
313
+ phase: 'snapshot',
314
+ status: 'failed',
315
+ unitId: unit.id,
316
+ error: { code: err.code, message: err.message },
317
+ });
318
+ // Preserve original stable error codes (PUBLIC_FILE_MISSING,
319
+ // SNAPSHOT_FIDELITY_FAILED, etc.) — do not wrap into GATE_FAILED.
320
+ throw err;
321
+ }
322
+
323
+ snapshotDigests.push(manifest.snapshotDigest);
324
+
325
+ await evidence.append({
326
+ phase: 'snapshot',
327
+ status: 'completed',
328
+ unitId: unit.id,
329
+ snapshotDigest: manifest.snapshotDigest,
330
+ fileCount: manifest.fileCount,
331
+ totalSize: manifest.totalSize,
332
+ });
333
+
334
+ // --- Scan for leakage ---
335
+ await evidence.append({
336
+ phase: 'scan',
337
+ status: 'started',
338
+ unitId: unit.id,
339
+ });
340
+
341
+ const findings = await scanSnapshot({
342
+ snapshotDir: outputDir,
343
+ policy: {
344
+ forbiddenPaths: config.policy?.forbiddenPaths ?? [],
345
+ forbiddenContentPatterns: config.policy?.forbiddenContentPatterns ?? [],
346
+ },
347
+ });
348
+
349
+ // Check for fatal findings (secrets, forbidden paths, forbidden content)
350
+ const FATAL_KINDS = new Set(['SECRET_DETECTED', 'PUBLIC_PATH_FORBIDDEN', 'FORBIDDEN_CONTENT_DETECTED']);
351
+ const fatalFindings = findings.filter((f) => FATAL_KINDS.has(f.kind));
352
+
353
+ if (fatalFindings.length > 0) {
354
+ await evidence.append({
355
+ phase: 'scan',
356
+ status: 'failed',
357
+ unitId: unit.id,
358
+ findings: fatalFindings.map((f) => ({
359
+ kind: f.kind,
360
+ file: f.file,
361
+ line: f.line,
362
+ message: f.message,
363
+ })),
364
+ });
365
+
366
+ // Use the specific error code for the first finding kind
367
+ const primaryKind = fatalFindings[0].kind;
368
+ const errorCode = primaryKind === 'FORBIDDEN_CONTENT_DETECTED'
369
+ ? FORBIDDEN_CONTENT_DETECTED
370
+ : GATE_FAILED;
371
+ throw new ReleaseError(
372
+ errorCode,
373
+ `leakage scan failed for unit "${unit.id}": ${fatalFindings.length} finding(s)`,
374
+ { unitId: unit.id, findings: fatalFindings },
375
+ );
376
+ }
377
+
378
+ // Non-fatal findings (stale build artifacts) are logged but allowed
379
+ const nonFatalFindings = findings.filter((f) => !FATAL_KINDS.has(f.kind));
380
+
381
+ await evidence.append({
382
+ phase: 'scan',
383
+ status: 'completed',
384
+ unitId: unit.id,
385
+ fatalCount: 0,
386
+ nonFatalCount: nonFatalFindings.length,
387
+ });
388
+
389
+ // --- Evaluate README ---
390
+ await evidence.append({
391
+ phase: 'readme',
392
+ status: 'started',
393
+ unitId: unit.id,
394
+ });
395
+
396
+ let readmeReport;
397
+ try {
398
+ readmeReport = await evaluateReadme({
399
+ snapshotDir: outputDir,
400
+ });
401
+ } catch (err) {
402
+ await evidence.append({
403
+ phase: 'readme',
404
+ status: 'failed',
405
+ unitId: unit.id,
406
+ error: { code: err.code, message: err.message },
407
+ });
408
+ throw new ReleaseError(
409
+ GATE_FAILED,
410
+ `README evaluation failed for unit "${unit.id}": ${err.message}`,
411
+ { unitId: unit.id, cause: err.code },
412
+ );
413
+ }
414
+
415
+ // Check required README markers — blocking finding for production prepare (Item 23)
416
+ if (readmeReport.missing.length > 0) {
417
+ if (production) {
418
+ await evidence.append({
419
+ phase: 'readme',
420
+ status: 'blocking',
421
+ unitId: unit.id,
422
+ missingMarkers: readmeReport.missing,
423
+ });
424
+ throw new ReleaseError(
425
+ GATE_FAILED,
426
+ `README missing required markers for unit "${unit.id}": ${readmeReport.missing.join(', ')}`,
427
+ { unitId: unit.id, missingMarkers: readmeReport.missing },
428
+ );
429
+ }
430
+ // Non-production: warn but don't block
431
+ await evidence.append({
432
+ phase: 'readme',
433
+ status: 'warning',
434
+ unitId: unit.id,
435
+ missingMarkers: readmeReport.missing,
436
+ });
437
+ }
438
+
439
+ // Check readability (Item 23): installation, example, diagnosis — blocking for production
440
+ const rc = readmeReport.readabilityChecks;
441
+ const missingReadability = [];
442
+ if (rc && !rc.hasInstall) missingReadability.push('install command');
443
+ if (rc && !rc.hasMinimalExample) missingReadability.push('minimal example');
444
+ if (rc && !rc.hasFailureDiagnosis) missingReadability.push('failure diagnosis');
445
+ if (missingReadability.length > 0) {
446
+ if (production) {
447
+ await evidence.append({
448
+ phase: 'readme',
449
+ status: 'blocking',
450
+ unitId: unit.id,
451
+ missingReadability,
452
+ });
453
+ throw new ReleaseError(
454
+ GATE_FAILED,
455
+ `README missing readability requirements for unit "${unit.id}": ${missingReadability.join(', ')}`,
456
+ { unitId: unit.id, missingReadability },
457
+ );
458
+ }
459
+ // Non-production: warn but don't block
460
+ await evidence.append({
461
+ phase: 'readme',
462
+ status: 'warning',
463
+ unitId: unit.id,
464
+ missingReadability,
465
+ });
466
+ }
467
+
468
+ await evidence.append({
469
+ phase: 'readme',
470
+ status: 'completed',
471
+ unitId: unit.id,
472
+ presentMarkers: readmeReport.present,
473
+ });
474
+
475
+ unitResults.push({
476
+ unit,
477
+ manifest,
478
+ readmeReport,
479
+ nonFatalFindings,
480
+ });
481
+ }
482
+
483
+ return { unitResults, snapshotDigests };
484
+ }
485
+
486
+ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir) {
487
+ for (const { unit } of unitResults) {
488
+ const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
489
+ if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
490
+ throw new ReleaseError(
491
+ GATE_FAILED,
492
+ `production npm distribution for unit "${unit.id}" requires explicit access: public or restricted`,
493
+ );
494
+ }
495
+ }
496
+ const assets = [];
497
+ for (let index = 0; index < unitResults.length; index += 1) {
498
+ const { unit, manifest } = unitResults[index];
499
+ const version = resolvedVersions[index];
500
+ const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
501
+ const tag = tagTemplate.replace('{version}', version);
502
+ const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
503
+ const branch = branchTemplate
504
+ .replaceAll('{tag}', tag)
505
+ .replaceAll('{version}', version)
506
+ .replaceAll('{unit}', unit.id);
507
+ const snapshotPath = relative(root, manifest.outputDir);
508
+ const observed = await computeFrozenSnapshot(manifest.outputDir);
509
+ if (observed.digest !== manifest.snapshotDigest) {
510
+ throw new ReleaseError(
511
+ GATE_FAILED,
512
+ `snapshot digest changed before production asset freeze for unit "${unit.id}"`,
513
+ { expected: manifest.snapshotDigest, observed: observed.digest },
514
+ );
515
+ }
516
+
517
+ // Freeze the byte/mode authority before deriving either distribution.
518
+ // Git and npm must consume the same immutable snapshot, never two reads of
519
+ // a writable staging directory separated by an attacker-controlled gap.
520
+ await sealFrozenSnapshot(manifest.outputDir);
521
+ const sealed = await computeFrozenSnapshot(manifest.outputDir);
522
+
523
+ const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
524
+ const git = await buildFrozenGitRepository({
525
+ snapshotDir: manifest.outputDir,
526
+ repositoryDir,
527
+ version,
528
+ expectedSnapshotDigest: sealed.digest,
529
+ });
530
+
531
+ let npm = null;
532
+ const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
533
+ if (npmDistribution) {
534
+ npm = await buildFrozenNpmTarball({
535
+ snapshotDir: manifest.outputDir,
536
+ tarballDir: resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id),
537
+ expectedSnapshotDigest: sealed.digest,
538
+ });
539
+ await verifyFrozenNpmTarballIdentity({
540
+ package: npmDistribution.package,
541
+ version,
542
+ tarballPath: relative(root, npm.tarballPath),
543
+ tarballSha256: npm.sha256,
544
+ integrity: npm.integrity,
545
+ }, root);
546
+ }
547
+
548
+ assets.push({
549
+ snapshotPath,
550
+ manifestDigest: sealed.digest,
551
+ gitObjectDir: relative(root, repositoryDir),
552
+ commit: git.commit,
553
+ tree: git.tree,
554
+ branch,
555
+ tag,
556
+ npm: npm ? {
557
+ tarballPath: relative(root, npm.tarballPath),
558
+ tarballSha256: npm.sha256,
559
+ integrity: npm.integrity,
560
+ size: npm.size,
561
+ } : null,
562
+ });
563
+ }
564
+ return assets;
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // External actions generation
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /**
572
+ * Build the list of external actions that would be taken during publish.
573
+ *
574
+ * Each action is in PENDING status. Actions are generated per unit and
575
+ * include: push-snapshot, create-tag, npm-publish, github-release.
576
+ *
577
+ * @param {object[]} unitResults - Results from processSnapshots.
578
+ * @param {string} planVersion - The target version.
579
+ * @param {string} realRoot - The project root for relative path calculation.
580
+ * @returns {object[]} Array of external action descriptors.
581
+ */
582
+ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
583
+ const actions = [];
584
+
585
+ const marketplaceIdentity = (distribution) => ({
586
+ plugin: distribution.plugin,
587
+ marketplace: distribution.marketplace,
588
+ entrySkill: distribution.entrySkill,
589
+ });
590
+
591
+ if (!productionAssets) {
592
+ for (let index = 0; index < unitResults.length; index += 1) {
593
+ const { unit } = unitResults[index];
594
+ const version = resolvedVersions[index];
595
+ const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
596
+ const tag = tagTemplate.replace('{version}', version);
597
+ actions.push({
598
+ id: `push-snapshot-${unit.id}`,
599
+ type: 'push-snapshot',
600
+ adapter: 'git-github',
601
+ unitId: unit.id,
602
+ parameters: { source: unit.source, publicRepo: unit.publicRepo, version, cwd: unit.source },
603
+ expected: { tag },
604
+ status: 'PENDING',
605
+ });
606
+ actions.push({
607
+ id: `create-tag-${unit.id}`,
608
+ type: 'create-tag',
609
+ adapter: 'git-github',
610
+ unitId: unit.id,
611
+ parameters: { tagTemplate, publicRepo: unit.publicRepo, version },
612
+ status: 'PENDING',
613
+ });
614
+ const npmDistribution = (unit.distributions ?? []).find((item) => item.type === 'npm');
615
+ if (npmDistribution) {
616
+ actions.push({
617
+ id: `npm-publish-${unit.id}`,
618
+ type: 'npm-publish',
619
+ adapter: 'npm',
620
+ unitId: unit.id,
621
+ parameters: {
622
+ package: npmDistribution.package,
623
+ version,
624
+ cwd: unit.source,
625
+ registry: npmDistribution.registry,
626
+ publisher: npmDistribution.publisher,
627
+ },
628
+ expected: {
629
+ package: npmDistribution.package,
630
+ version,
631
+ registry: npmDistribution.registry,
632
+ publisher: npmDistribution.publisher,
633
+ },
634
+ status: 'PENDING',
635
+ });
636
+ }
637
+ actions.push({
638
+ id: `github-release-${unit.id}`,
639
+ type: 'github-release',
640
+ adapter: 'github',
641
+ unitId: unit.id,
642
+ parameters: { publicRepo: unit.publicRepo, version },
643
+ status: 'PENDING',
644
+ });
645
+ // Consumer marketplace install actions (only when distribution declared)
646
+ const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
647
+ if (claudeDist) {
648
+ const identity = marketplaceIdentity(claudeDist);
649
+ actions.push({
650
+ id: `claude-marketplace-install-${unit.id}`,
651
+ type: 'claude-marketplace-install',
652
+ adapter: 'plugin-marketplace',
653
+ unitId: unit.id,
654
+ parameters: {
655
+ consumer: 'claude',
656
+ plugin: identity.plugin,
657
+ marketplace: identity.marketplace,
658
+ repo: unit.publicRepo,
659
+ version,
660
+ entrySkill: identity.entrySkill,
661
+ },
662
+ expected: {
663
+ installed: true,
664
+ plugin: identity.plugin,
665
+ marketplace: identity.marketplace,
666
+ version,
667
+ entrySkill: identity.entrySkill,
668
+ },
669
+ status: 'PENDING',
670
+ });
671
+ }
672
+ const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
673
+ if (codexDist) {
674
+ const identity = marketplaceIdentity(codexDist);
675
+ actions.push({
676
+ id: `codex-marketplace-install-${unit.id}`,
677
+ type: 'codex-marketplace-install',
678
+ adapter: 'plugin-marketplace',
679
+ unitId: unit.id,
680
+ parameters: {
681
+ consumer: 'codex',
682
+ plugin: identity.plugin,
683
+ marketplace: identity.marketplace,
684
+ repo: unit.publicRepo,
685
+ version,
686
+ entrySkill: identity.entrySkill,
687
+ },
688
+ expected: {
689
+ installed: true,
690
+ plugin: identity.plugin,
691
+ marketplace: identity.marketplace,
692
+ version,
693
+ entrySkill: identity.entrySkill,
694
+ },
695
+ status: 'PENDING',
696
+ });
697
+ }
698
+ }
699
+ return actions;
700
+ }
701
+
702
+ for (let index = 0; index < unitResults.length; index += 1) {
703
+ const { unit } = unitResults[index];
704
+ const unitVersion = resolvedVersions[index];
705
+ const asset = productionAssets[index];
706
+ const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
707
+ const resolvedTag = asset.tag;
708
+
709
+ // Push snapshot
710
+ actions.push({
711
+ id: `push-snapshot-${unit.id}`,
712
+ type: 'push-snapshot',
713
+ adapter: 'git-github',
714
+ unitId: unit.id,
715
+ parameters: {
716
+ source: unit.source,
717
+ publicRepo: unit.publicRepo,
718
+ version: unitVersion,
719
+ cwd: unit.source,
720
+ snapshotPath: asset.snapshotPath,
721
+ manifestDigest: asset.manifestDigest,
722
+ gitObjectDir: asset.gitObjectDir,
723
+ branch: asset.branch,
724
+ repo: unit.publicRepo,
725
+ githubHost: unit.production?.githubHost ?? 'github.com',
726
+ commit: asset.commit,
727
+ tree: asset.tree,
728
+ },
729
+ expected: {
730
+ branch: asset.branch,
731
+ commit: asset.commit,
732
+ tree: asset.tree,
733
+ manifestDigest: asset.manifestDigest,
734
+ },
735
+ status: 'PENDING',
736
+ });
737
+
738
+ // Create tag
739
+ actions.push({
740
+ id: `create-tag-${unit.id}`,
741
+ type: 'create-tag',
742
+ adapter: 'git-github',
743
+ unitId: unit.id,
744
+ parameters: {
745
+ tagTemplate,
746
+ publicRepo: unit.publicRepo,
747
+ version: unitVersion,
748
+ tag: resolvedTag,
749
+ repo: unit.publicRepo,
750
+ githubHost: unit.production?.githubHost ?? 'github.com',
751
+ gitObjectDir: asset.gitObjectDir,
752
+ commit: asset.commit,
753
+ },
754
+ expected: { tag: resolvedTag, commit: asset.commit },
755
+ status: 'PENDING',
756
+ });
757
+
758
+ // npm publish (only for npm distributions)
759
+ const npmDist = (unit.distributions ?? []).find((d) => d.type === 'npm');
760
+ if (npmDist) {
761
+ actions.push({
762
+ id: `npm-publish-${unit.id}`,
763
+ type: 'npm-publish',
764
+ adapter: 'npm',
765
+ unitId: unit.id,
766
+ parameters: {
767
+ package: npmDist.package,
768
+ version: unitVersion,
769
+ cwd: unit.source,
770
+ tarballPath: asset.npm.tarballPath,
771
+ tarballSha256: asset.npm.tarballSha256,
772
+ integrity: asset.npm.integrity,
773
+ access: npmDist.access,
774
+ provenance: npmDist.provenance === true,
775
+ ...(npmDist.tag ? { tag: npmDist.tag } : {}),
776
+ registry: npmDist.registry,
777
+ publisher: npmDist.publisher,
778
+ },
779
+ expected: {
780
+ package: npmDist.package,
781
+ version: unitVersion,
782
+ integrity: asset.npm.integrity,
783
+ registry: npmDist.registry,
784
+ publisher: npmDist.publisher,
785
+ },
786
+ status: 'PENDING',
787
+ });
788
+ }
789
+
790
+ // GitHub release
791
+ actions.push({
792
+ id: `github-release-${unit.id}`,
793
+ type: 'github-release',
794
+ adapter: 'github',
795
+ unitId: unit.id,
796
+ parameters: {
797
+ publicRepo: unit.publicRepo,
798
+ version: unitVersion,
799
+ tag: resolvedTag,
800
+ repo: unit.publicRepo,
801
+ githubHost: unit.production?.githubHost ?? 'github.com',
802
+ commit: asset.commit,
803
+ name: (unit.production?.releaseTitleTemplate ?? 'Release {tag}')
804
+ .replaceAll('{tag}', resolvedTag)
805
+ .replaceAll('{version}', unitVersion)
806
+ .replaceAll('{unit}', unit.id),
807
+ notes: unit.production?.releaseNotes ?? `Release ${resolvedTag}`,
808
+ },
809
+ expected: {
810
+ tag: resolvedTag,
811
+ commit: asset.commit,
812
+ },
813
+ status: 'PENDING',
814
+ });
815
+
816
+ // Consumer marketplace install actions (only when distribution declared)
817
+ const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
818
+ if (claudeDist) {
819
+ const identity = marketplaceIdentity(claudeDist);
820
+ actions.push({
821
+ id: `claude-marketplace-install-${unit.id}`,
822
+ type: 'claude-marketplace-install',
823
+ adapter: 'plugin-marketplace',
824
+ unitId: unit.id,
825
+ parameters: {
826
+ consumer: 'claude',
827
+ plugin: identity.plugin,
828
+ marketplace: identity.marketplace,
829
+ repo: unit.publicRepo,
830
+ ref: resolvedTag,
831
+ version: unitVersion,
832
+ entrySkill: identity.entrySkill,
833
+ snapshotPath: asset.snapshotPath,
834
+ manifestDigest: asset.manifestDigest,
835
+ },
836
+ expected: {
837
+ installed: true,
838
+ consumer: 'claude',
839
+ plugin: identity.plugin,
840
+ marketplace: identity.marketplace,
841
+ repo: unit.publicRepo,
842
+ version: unitVersion,
843
+ ref: resolvedTag,
844
+ entrySkill: identity.entrySkill,
845
+ entrySkillFound: true,
846
+ manifestDigest: asset.manifestDigest,
847
+ },
848
+ status: 'PENDING',
849
+ });
850
+ }
851
+ const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
852
+ if (codexDist) {
853
+ const identity = marketplaceIdentity(codexDist);
854
+ actions.push({
855
+ id: `codex-marketplace-install-${unit.id}`,
856
+ type: 'codex-marketplace-install',
857
+ adapter: 'plugin-marketplace',
858
+ unitId: unit.id,
859
+ parameters: {
860
+ consumer: 'codex',
861
+ plugin: identity.plugin,
862
+ marketplace: identity.marketplace,
863
+ repo: unit.publicRepo,
864
+ ref: resolvedTag,
865
+ version: unitVersion,
866
+ entrySkill: identity.entrySkill,
867
+ snapshotPath: asset.snapshotPath,
868
+ manifestDigest: asset.manifestDigest,
869
+ },
870
+ expected: {
871
+ installed: true,
872
+ consumer: 'codex',
873
+ plugin: identity.plugin,
874
+ marketplace: identity.marketplace,
875
+ repo: unit.publicRepo,
876
+ version: unitVersion,
877
+ ref: resolvedTag,
878
+ entrySkill: identity.entrySkill,
879
+ entrySkillFound: true,
880
+ manifestDigest: asset.manifestDigest,
881
+ },
882
+ status: 'PENDING',
883
+ });
884
+ }
885
+ }
886
+
887
+ return actions;
888
+ }
889
+
890
+ // ---------------------------------------------------------------------------
891
+ // Public API
892
+ // ---------------------------------------------------------------------------
893
+
894
+ /**
895
+ * Run the full prepare pipeline and freeze a release plan.
896
+ *
897
+ * @param {Object} options
898
+ * @param {string} options.root - Absolute path to the project root.
899
+ * @param {string} [options.version] - Target version override. If not provided,
900
+ * each unit's version is read from its configured source.
901
+ * @param {boolean} [options.offline=true] - Skip remote checks when true.
902
+ * @param {string} [options.output] - Path to write the plan. Defaults to
903
+ * `<root>/.release-skill/release-plan.json`.
904
+ * @param {string} [options.runDir] - Directory for evidence. Defaults to
905
+ * `<root>/.release-skill/runs/prepare-<timestamp>`.
906
+ * @param {() => string} [options.clock] - Clock function for timestamps.
907
+ * @param {boolean} [options.hooksAuthorized] - Must be explicitly `true` when
908
+ * the project config declares hooks. Hooks are user-configured arbitrary
909
+ * local processes without filesystem/network isolation. Authorization
910
+ * means the user accepts hook side-effect risks, not that hooks are safe.
911
+ *
912
+ * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
913
+ *
914
+ * @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
915
+ */
916
+ export async function prepareRelease(options) {
917
+ const {
918
+ root,
919
+ version,
920
+ offline = true,
921
+ output,
922
+ runDir: runDirOpt,
923
+ clock,
924
+ hooksAuthorized,
925
+ production = false,
926
+ observePreviousPublicBaselineFn,
927
+ } = options ?? {};
928
+
929
+ // --- Validate root ---
930
+ if (!root || typeof root !== 'string') {
931
+ throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
932
+ }
933
+
934
+ // Resolve root to real path (follows system symlinks like macOS /var → /private/var).
935
+ // This ensures outputDir paths use the real filesystem path, avoiding false
936
+ // positives in ancestor symlink checks.
937
+ let realRoot;
938
+ try {
939
+ realRoot = await realpath(root);
940
+ } catch (err) {
941
+ throw new ReleaseError(
942
+ CONFIG_INVALID,
943
+ `cannot resolve root path: ${err.message}`,
944
+ { root, cause: err.code },
945
+ );
946
+ }
947
+
948
+ if (production) {
949
+ const canonicalOutput = resolve(realRoot, '.release-skill', 'release-plan.json');
950
+ if (output && resolve(output) !== canonicalOutput) {
951
+ throw new ReleaseError(
952
+ GATE_FAILED,
953
+ 'production prepare requires the canonical .release-skill/release-plan.json output; custom --output is supported only outside production',
954
+ { output: resolve(output), expected: canonicalOutput },
955
+ );
956
+ }
957
+ }
958
+
959
+ // --- Acquire project lock (shared domain with all mutating artifact commands) ---
960
+ const lock = await acquireProjectLock({ root: realRoot, command: 'prepare', mode: 'exclusive' });
961
+
962
+ // --- Set up directories ---
963
+ // Use realRoot for directory construction to avoid system symlink issues
964
+ // (e.g., macOS /var → /private/var) in outputDir ancestor checks.
965
+ const releaseDir = resolve(realRoot, '.release-skill');
966
+ const runId = `prepare-${Date.now()}`;
967
+ const rawRunDir = runDirOpt ?? resolve(releaseDir, 'runs', runId);
968
+ let runDir;
969
+ try {
970
+ if (production) {
971
+ runDir = await createProductionPrepareRunDir(rawRunDir, releaseDir);
972
+ } else {
973
+ await mkdir(rawRunDir, { recursive: true });
974
+ // Resolve after mkdir to canonicalize system aliases such as /var → /private/var.
975
+ runDir = await realpath(rawRunDir);
976
+ }
977
+ } catch (error) {
978
+ await lock.release();
979
+ throw error;
980
+ }
981
+ const evidenceDir = runDir;
982
+
983
+ // --- Evidence writer ---
984
+ const evidence = createEvidenceWriter({ runDir, command: 'prepare', clock });
985
+
986
+ try {
987
+ // --- Step 1: Load and validate config ---
988
+ await evidence.append({ phase: 'config', status: 'started' });
989
+
990
+ const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
991
+
992
+ await evidence.append({
993
+ phase: 'config',
994
+ status: 'completed',
995
+ configPath: relative(realRoot, configPath),
996
+ configDigest,
997
+ });
998
+
999
+ // --- Step 2: Hook authorization gate ---
1000
+ // Hooks are user-configured arbitrary local processes without filesystem
1001
+ // or network isolation. They may write outside the project, access local
1002
+ // credentials, or make network calls. The user must explicitly accept
1003
+ // these risks before any hook is executed.
1004
+ const declaredHooks = Object.entries(config.hooks ?? {})
1005
+ .filter(([, hook]) => hook && hook.command)
1006
+ .map(([name, hook]) => ({
1007
+ name,
1008
+ executable: hook.command[0],
1009
+ args: hook.command.slice(1),
1010
+ cwd: hook.cwd ?? '.',
1011
+ }));
1012
+
1013
+ if (declaredHooks.length > 0) {
1014
+ await evidence.append({
1015
+ phase: 'hook-authorization',
1016
+ status: 'started',
1017
+ hookCount: declaredHooks.length,
1018
+ hooks: declaredHooks.map((h) => `${h.name}: ${h.executable} ${h.args.join(' ')}`),
1019
+ });
1020
+
1021
+ if (hooksAuthorized !== true) {
1022
+ const hookList = declaredHooks
1023
+ .map((h) => ` - ${h.name}: executable="${h.executable}", args=[${h.args.join(', ')}], cwd="${h.cwd}"`)
1024
+ .join('\n');
1025
+
1026
+ await evidence.append({
1027
+ phase: 'hook-authorization',
1028
+ status: 'denied',
1029
+ reason: 'hooks not explicitly authorized',
1030
+ });
1031
+
1032
+ throw new ReleaseError(
1033
+ GATE_FAILED,
1034
+ `project declares ${declaredHooks.length} hook(s) that will be executed as arbitrary local processes.\n` +
1035
+ `These hooks are NOT sandboxed — they may write to the filesystem outside the project, ` +
1036
+ `access local credentials, or make network calls.\n` +
1037
+ `The following hooks will run:\n${hookList}\n\n` +
1038
+ `To proceed, pass --acknowledge-hook-side-effects (CLI) or hooksAuthorized=true (API). ` +
1039
+ `Authorization means you accept hook side-effect risks; it does NOT make hooks safe.`,
1040
+ { hookNames: declaredHooks.map((h) => h.name), hookCount: declaredHooks.length },
1041
+ );
1042
+ }
1043
+
1044
+ await evidence.append({
1045
+ phase: 'hook-authorization',
1046
+ status: 'authorized',
1047
+ hookCount: declaredHooks.length,
1048
+ });
1049
+ }
1050
+
1051
+ // --- Step 3: Run declared hooks ---
1052
+ await evidence.append({ phase: 'hooks', status: 'started' });
1053
+ await runDeclaredHooks(config, realRoot, evidence);
1054
+ await evidence.append({ phase: 'hooks', status: 'completed' });
1055
+
1056
+ // --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
1057
+ // reflects any file changes introduced by hooks) ---
1058
+ await evidence.append({ phase: 'baseline', status: 'started' });
1059
+
1060
+ const baseline = await captureBaseline(realRoot);
1061
+
1062
+ await evidence.append({
1063
+ phase: 'baseline',
1064
+ status: 'completed',
1065
+ gitTreeHash: baseline.gitTreeHash,
1066
+ headCommit: baseline.gitHead,
1067
+ dirtyFileCount: baseline.statusEntries.length,
1068
+ });
1069
+
1070
+ // --- Step 4b: Per-unit previous public baseline observe ---
1071
+ const configUnits = config.releaseUnits ?? [];
1072
+ const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
1073
+ try {
1074
+ const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
1075
+ shell: false, encoding: "utf8", timeout: 30000,
1076
+ });
1077
+ const lines = stdout.trim().split("\n").filter(l => l.length > 0);
1078
+ if (lines.length === 0) return { status: "drifted", actual: null, diff: "ref not found on remote" };
1079
+ const [remoteCommit] = lines[0].split("\t");
1080
+ if (remoteCommit === expectedCommit) return { status: "consistent", actual: remoteCommit };
1081
+ return { status: "drifted", actual: remoteCommit, diff: "expected " + expectedCommit + ", got " + remoteCommit };
1082
+ } catch (err) {
1083
+ return { status: "unknown", error: err.message };
1084
+ }
1085
+ };
1086
+ const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
1087
+ const unitBaselineResults = new Map();
1088
+ for (const unit of configUnits) {
1089
+ const ppbConfig = unit.previousPublicBaseline;
1090
+ if (!ppbConfig) continue;
1091
+ const productionGithubHost = unit.production?.githubHost ?? 'github.com';
1092
+ const effectivePpbConfig = ppbConfig.mode === 'bound'
1093
+ ? { ...ppbConfig, githubHost: productionGithubHost }
1094
+ : ppbConfig;
1095
+ assertPreviousPublicBaselineTarget({
1096
+ baseline: effectivePpbConfig,
1097
+ githubHost: productionGithubHost,
1098
+ publicRepo: unit.publicRepo,
1099
+ });
1100
+
1101
+ if (ppbConfig.mode === "none") {
1102
+ unitBaselineResults.set(unit.id, {
1103
+ mode: "none",
1104
+ status: "consistent",
1105
+ });
1106
+ await evidence.append({
1107
+ phase: "previous-public-baseline",
1108
+ unitId: unit.id,
1109
+ status: "skipped",
1110
+ reason: "fresh repository",
1111
+ });
1112
+ continue;
1113
+ }
1114
+
1115
+ if (offline) {
1116
+ // Production + bound + offline: fail closed before plan write
1117
+ if (production) {
1118
+ await evidence.append({
1119
+ phase: "previous-public-baseline",
1120
+ unitId: unit.id,
1121
+ status: "blocking",
1122
+ repo: ppbConfig.repo,
1123
+ ref: ppbConfig.ref,
1124
+ commit: ppbConfig.commit,
1125
+ reason: "production bound baseline requires --online observation",
1126
+ });
1127
+ throw new ReleaseError(
1128
+ GATE_FAILED,
1129
+ `unit "${unit.id}" has bound previousPublicBaseline but production prepare uses --offline. ` +
1130
+ `Must use --online to observe the previous public baseline before freezing a production plan.`,
1131
+ { unitId: unit.id, repo: ppbConfig.repo, ref: ppbConfig.ref },
1132
+ );
1133
+ }
1134
+ // Non-production offline: record unobserved-offline for local assessment
1135
+ unitBaselineResults.set(unit.id, {
1136
+ mode: "bound",
1137
+ githubHost: productionGithubHost,
1138
+ repo: ppbConfig.repo,
1139
+ ref: ppbConfig.ref,
1140
+ commit: ppbConfig.commit,
1141
+ status: "unobserved-offline",
1142
+ });
1143
+ await evidence.append({
1144
+ phase: "previous-public-baseline",
1145
+ unitId: unit.id,
1146
+ status: "unobserved-offline",
1147
+ reason: "offline mode",
1148
+ });
1149
+ continue;
1150
+ }
1151
+
1152
+ // Online bound: observe the remote ref
1153
+ await evidence.append({
1154
+ phase: "previous-public-baseline",
1155
+ unitId: unit.id,
1156
+ status: "started",
1157
+ repo: ppbConfig.repo,
1158
+ ref: ppbConfig.ref,
1159
+ });
1160
+
1161
+ let result;
1162
+ try {
1163
+ result = await observePreviousPublicBaseline({
1164
+ baseline: effectivePpbConfig,
1165
+ observeFn,
1166
+ evidence,
1167
+ });
1168
+ } catch (err) {
1169
+ // Observe failed (drifted or unknown): write blocking evidence with resolution options
1170
+ const observation = err?.details ?? {};
1171
+ const mappingDiff = observation.diff
1172
+ ? { status: "available", summary: observation.diff }
1173
+ : {
1174
+ status: "unavailable",
1175
+ reason: observation.error
1176
+ ? `remote mapping observation failed: ${observation.error}`
1177
+ : "remote ref-to-commit mapping could not be determined",
1178
+ };
1179
+ await evidence.append({
1180
+ phase: "previous-public-baseline",
1181
+ unitId: unit.id,
1182
+ status: "blocking",
1183
+ repo: ppbConfig.repo,
1184
+ ref: ppbConfig.ref,
1185
+ expected: ppbConfig.commit,
1186
+ expectedCommit: ppbConfig.commit,
1187
+ actual: observation.actual ?? null,
1188
+ diff: observation.diff ?? null,
1189
+ mappingDiff,
1190
+ contentDiff: {
1191
+ status: "unavailable",
1192
+ reason: "the default previous-baseline observer resolves only ref-to-commit mapping and does not fetch remote content",
1193
+ },
1194
+ error: { code: err.code, message: err.message },
1195
+ resolutionOptions: ["merge", "adopt", "reject"],
1196
+ guidance: "把已采用改动合并回 human-owned 权威源后重新 prepare",
1197
+ });
1198
+ throw err;
1199
+ }
1200
+
1201
+ unitBaselineResults.set(unit.id, {
1202
+ mode: "bound",
1203
+ githubHost: productionGithubHost,
1204
+ repo: ppbConfig.repo,
1205
+ ref: ppbConfig.ref,
1206
+ commit: ppbConfig.commit,
1207
+ observedCommit: result?.observed?.actual ?? ppbConfig.commit,
1208
+ observedAt: (clock ? clock() : new Date().toISOString()),
1209
+ status: "consistent",
1210
+ });
1211
+ await evidence.append({
1212
+ phase: "previous-public-baseline",
1213
+ unitId: unit.id,
1214
+ status: "completed",
1215
+ consistent: true,
1216
+ });
1217
+ }
1218
+
1219
+ // --- Step 5: Build snapshots, scan, and evaluate README ---
1220
+ const { unitResults, snapshotDigests } = await processSnapshots(
1221
+ config, realRoot, evidence, runDir, production,
1222
+ );
1223
+
1224
+ // --- Step 6: Remote uniqueness (deferred to publish preflight) ---
1225
+ // Prepare only observes the previous public baseline (already done above).
1226
+ // Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
1227
+ // to the publish phase's global preflight, which runs before any execute.
1228
+ if (!offline) {
1229
+ await evidence.append({
1230
+ phase: 'remote-check',
1231
+ status: 'deferred',
1232
+ reason: 'remote uniqueness checks (tag, GitHub Release, npm version) deferred to publish global preflight',
1233
+ });
1234
+ } else if (production) {
1235
+ await evidence.append({
1236
+ phase: 'remote-check',
1237
+ status: 'deferred',
1238
+ reason: 'offline production prepare is allowed only for an explicit fresh baseline; target branch, tag, GitHub Release, and npm uniqueness are deferred to publish global preflight before any execute',
1239
+ });
1240
+ } else {
1241
+ await evidence.append({ phase: 'remote-check', status: 'skipped', reason: 'offline mode' });
1242
+ }
1243
+
1244
+ // --- Step 7: Build plan object ---
1245
+ await evidence.append({ phase: 'plan-assembly', status: 'started' });
1246
+
1247
+ // Resolve versions for all units
1248
+ const resolvedVersions = await resolveAllUnitVersions(
1249
+ config.releaseUnits ?? [],
1250
+ realRoot,
1251
+ version,
1252
+ evidence,
1253
+ );
1254
+
1255
+ const productionAssets = production
1256
+ ? await buildProductionAssets(unitResults, resolvedVersions, realRoot, runDir)
1257
+ : null;
1258
+
1259
+ const units = unitResults.map(({ unit, manifest }, idx) => {
1260
+ const unitVersion = resolvedVersions[idx];
1261
+ const unitBaseline = unitBaselineResults.get(unit.id);
1262
+ return {
1263
+ id: unit.id,
1264
+ targetVersion: unitVersion,
1265
+ source: unit.source,
1266
+ publicRepo: unit.publicRepo,
1267
+ tagTemplate: unit.version?.tagTemplate,
1268
+ snapshotDigest: snapshotDigests[idx],
1269
+ ...(productionAssets ? {
1270
+ productionConfig: unit.production ?? {},
1271
+ frozenSnapshot: {
1272
+ path: productionAssets[idx].snapshotPath,
1273
+ manifestDigest: productionAssets[idx].manifestDigest,
1274
+ gitObjectDir: productionAssets[idx].gitObjectDir,
1275
+ branch: productionAssets[idx].branch,
1276
+ commit: productionAssets[idx].commit,
1277
+ tree: productionAssets[idx].tree,
1278
+ npm: productionAssets[idx].npm,
1279
+ },
1280
+ } : {}),
1281
+ distributions: unit.distributions,
1282
+ ...(unitBaseline ? { previousPublicBaseline: unitBaseline } : {}),
1283
+ };
1284
+ });
1285
+
1286
+ const externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets);
1287
+
1288
+ // Compute overall snapshot digest
1289
+ const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
1290
+
1291
+ const plan = {
1292
+ planVersion: 1,
1293
+ status: 'PREPARED',
1294
+ baseline: {
1295
+ gitTreeHash: baseline.gitTreeHash,
1296
+ headCommit: baseline.gitHead,
1297
+ workspaceDigestAlgorithm: baseline.workspaceDigestAlgorithm,
1298
+ workspaceDigest: baseline.workspaceDigest,
1299
+ dirtyFiles: baseline.statusEntries,
1300
+ capturedAt: baseline.capturedAt,
1301
+ },
1302
+ configDigest,
1303
+ snapshotDigest: overallSnapshotDigest,
1304
+ ...(production ? {
1305
+ production: {
1306
+ mode: 'github-npm-v1',
1307
+ assetRoot: relative(realRoot, runDir),
1308
+ },
1309
+ } : {}),
1310
+ units,
1311
+ externalActions,
1312
+ createdAt: (clock ? clock() : new Date().toISOString()),
1313
+ };
1314
+
1315
+ await evidence.append({
1316
+ phase: 'plan-assembly',
1317
+ status: 'completed',
1318
+ unitCount: units.length,
1319
+ actionCount: externalActions.length,
1320
+ });
1321
+
1322
+ // --- Step 8: Validate and write plan atomically ---
1323
+ await evidence.append({ phase: 'plan-write', status: 'started' });
1324
+
1325
+ const latestPlanPath = output ?? resolve(releaseDir, 'release-plan.json');
1326
+ const plannedDigest = computePlanDigest(plan);
1327
+ const immutablePlanPath = resolve(dirname(latestPlanPath), 'plans', `${plannedDigest}.json`);
1328
+ const { planPath: writtenPath, planDigest } = await writePlanImmutable(immutablePlanPath, plan);
1329
+ // This is a convenience copy only. All downstream authority uses the
1330
+ // digest-addressed immutable path returned above.
1331
+ await writePlanAtomic(latestPlanPath, plan);
1332
+
1333
+ await evidence.append({
1334
+ phase: 'plan-write',
1335
+ status: 'completed',
1336
+ planPath: writtenPath,
1337
+ planDigest,
1338
+ });
1339
+
1340
+ // --- Write summary ---
1341
+ await evidence.finish({
1342
+ status: 'PREPARED',
1343
+ planPath: writtenPath,
1344
+ planDigest,
1345
+ configDigest,
1346
+ snapshotDigest: overallSnapshotDigest,
1347
+ unitCount: units.length,
1348
+ actionCount: externalActions.length,
1349
+ offline,
1350
+ completedAt: (clock ? clock() : new Date().toISOString()),
1351
+ });
1352
+
1353
+ return {
1354
+ planPath: writtenPath,
1355
+ planDigest,
1356
+ evidenceDir,
1357
+ };
1358
+ } catch (err) {
1359
+ // Record failure evidence
1360
+ await evidence.append({
1361
+ phase: 'prepare',
1362
+ status: 'failed',
1363
+ error: { code: err.code, message: err.message },
1364
+ });
1365
+
1366
+ await evidence.finish({
1367
+ status: 'FAILED',
1368
+ error: { code: err.code, message: err.message },
1369
+ failedAt: (clock ? clock() : new Date().toISOString()),
1370
+ });
1371
+
1372
+ throw err;
1373
+ } finally {
1374
+ // Release project lock — always, even on failure
1375
+ await lock.release();
1376
+ }
1377
+ }