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,883 @@
1
+ /**
2
+ * Publish command: Saga-pattern checkpoint execution with safety gates.
3
+ *
4
+ * Reads a frozen, approved release plan and executes external actions
5
+ * through registered adapters. Every step is a checkpoint: failure stops
6
+ * subsequent actions and records PARTIAL status.
7
+ *
8
+ * Safety gates (all verified before any adapter execute):
9
+ * 1. Plan schema validation
10
+ * 2. Plan digest verification
11
+ * 3. Approval record schema validation
12
+ * 4. Approval-plan digest match
13
+ * 5. Approval expiry check
14
+ * 6. Target version match
15
+ * 7. Approved actions allowlist check
16
+ * 8. Action type adapter availability
17
+ * 9. Baseline hash comparison (rejects stale baseline, calls zero adapter execute)
18
+ * 10. Remote preflight (adapter-level)
19
+ *
20
+ * Invariants:
21
+ * - Baseline change => BASELINE_CHANGED, zero adapter execute calls
22
+ * - Any checkpoint failure => PARTIAL, no subsequent adapter execute calls
23
+ * - System never auto-deletes remote tags, overwrites releases, or unpublishes npm
24
+ *
25
+ * @module commands/publish
26
+ */
27
+
28
+ import { readFile, mkdir } from 'node:fs/promises';
29
+ import { isAbsolute, join, relative } from 'node:path';
30
+
31
+ import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
32
+ import {
33
+ assertImmutableApprovalAuthority,
34
+ computeApprovalDigest,
35
+ validateApproval,
36
+ validateApprovalRecordSchema,
37
+ } from '../core/approval.mjs';
38
+ import { captureBaseline, WORKSPACE_DIGEST_ALGORITHM } from '../core/baseline.mjs';
39
+ import {
40
+ assertPreviousPublicBaselineTarget,
41
+ reObservePreviousPublicBaseline,
42
+ } from '../core/previous-public-baseline.mjs';
43
+ import { createEvidenceWriter } from '../core/evidence.mjs';
44
+ import { appendRunState, createProductionRunDir, writeRunAtomic, resolveDefaultRunDir } from '../core/run.mjs';
45
+ import {
46
+ ReleaseError,
47
+ GATE_FAILED,
48
+ BASELINE_CHANGED,
49
+ PARTIAL_RELEASE,
50
+ } from '../core/errors.mjs';
51
+ import { assertTransition, PUBLISHING, PUBLISHED, PARTIAL } from '../core/state-machine.mjs';
52
+ import { matchObservation } from '../adapters/contract.mjs';
53
+ import {
54
+ resolveFrozenPath,
55
+ verifyFrozenFile,
56
+ verifyFrozenGitRepository,
57
+ verifyFrozenSnapshot,
58
+ } from '../snapshot/frozen.mjs';
59
+ import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
60
+
61
+ function assertInsideAssetRoot(assetRoot, candidate, label) {
62
+ const rel = relative(assetRoot, candidate);
63
+ if (rel === '' || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
64
+ throw new ReleaseError(GATE_FAILED, `${label} must be a child of the production asset root`);
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Constants
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
73
+
74
+ /** Checkpoint order for the publish saga. */
75
+ const CHECKPOINT_ORDER = [
76
+ 'push-commit',
77
+ 'push-snapshot',
78
+ 'create-tag',
79
+ 'npm-publish',
80
+ 'github-release',
81
+ 'claude-marketplace-install',
82
+ 'codex-marketplace-install',
83
+ ];
84
+
85
+ /**
86
+ * Map plan action type to adapter ActionType.
87
+ *
88
+ * Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
89
+ * `github-release`. The adapter contract uses `git-push`, `git-tag`,
90
+ * `npm-publish`, `github-release`.
91
+ */
92
+ const ADAPTER_ACTION_TYPE_MAP = {
93
+ 'push-commit': 'git-push',
94
+ 'push-snapshot': 'push-snapshot',
95
+ 'create-tag': 'git-tag',
96
+ 'npm-publish': 'npm-publish',
97
+ 'github-release': 'github-release',
98
+ 'claude-marketplace-install': 'claude-marketplace-install',
99
+ 'codex-marketplace-install': 'codex-marketplace-install',
100
+ };
101
+
102
+ const MARKETPLACE_TYPES = new Set([
103
+ 'claude-marketplace-install',
104
+ 'codex-marketplace-install',
105
+ ]);
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function defaultClock() {
112
+ return new Date().toISOString();
113
+ }
114
+
115
+ /**
116
+ * Deep-clone a JSON-serialisable value.
117
+ */
118
+ function deepClone(obj) {
119
+ return JSON.parse(JSON.stringify(obj));
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Checkpoint execution
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Execute a single checkpoint action through the adapter registry.
128
+ *
129
+ * @param {Object} action - The external action from the plan.
130
+ * @param {Object} adapterRegistry - The adapter registry.
131
+ * @param {Object} context - Adapter context (plan, baseline, root, externalWritesAuthorized).
132
+ * @returns {Promise<{ actionId: string, status: string, error: string|null }>}
133
+ */
134
+ async function executeCheckpoint(action, adapterRegistry, context) {
135
+ const { id: actionId, type: planActionType } = action;
136
+
137
+ // write-remote-identifier is a meta-checkpoint: update the plan with
138
+ // resource identifiers. No external adapter call.
139
+ if (planActionType === 'write-remote-identifier') {
140
+ return { actionId, status: 'SUCCEEDED', error: null };
141
+ }
142
+
143
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[planActionType];
144
+ if (!adapterActionType) {
145
+ return {
146
+ actionId,
147
+ status: 'FAILED',
148
+ error: `Unknown action type: ${planActionType}`,
149
+ };
150
+ }
151
+
152
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
153
+ if (!adapter) {
154
+ return {
155
+ actionId,
156
+ status: 'FAILED',
157
+ error: `No adapter for action type: ${adapterActionType}`,
158
+ };
159
+ }
160
+
161
+ // Preflight (read-only, no authorization required)
162
+ const preflightResult = await adapter.preflight(
163
+ { actionType: adapterActionType, ...action.parameters },
164
+ context,
165
+ );
166
+ if (preflightResult.status === 'PREFLIGHT_FAILED') {
167
+ return {
168
+ actionId,
169
+ status: 'FAILED',
170
+ error: preflightResult.error ?? 'Preflight failed',
171
+ };
172
+ }
173
+
174
+ // Execute (write action, requires externalWritesAuthorized)
175
+ let executeResult;
176
+ let executeError = null;
177
+ try {
178
+ executeResult = await adapter.execute(
179
+ { actionType: adapterActionType, ...action.parameters },
180
+ context,
181
+ );
182
+ } catch (error) {
183
+ executeError = error;
184
+ }
185
+
186
+ // Once execute was attempted, its return value is not authoritative: the
187
+ // remote may have accepted the write before the connection failed. Always
188
+ // observe before classifying the checkpoint.
189
+ let observeResult;
190
+ try {
191
+ observeResult = await adapter.observe(
192
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
193
+ context,
194
+ );
195
+ } catch (error) {
196
+ return {
197
+ actionId,
198
+ status: 'UNCERTAIN',
199
+ error: `execute outcome is uncertain; observe threw: ${error.message}`,
200
+ };
201
+ }
202
+
203
+ const observation = observeResult?.observation;
204
+ if (!observation || (observeResult.error && Object.keys(observation).length === 0)) {
205
+ return {
206
+ actionId,
207
+ status: 'UNCERTAIN',
208
+ error: `execute outcome is uncertain; observe failed: ${observeResult?.error ?? 'empty observation'}`,
209
+ };
210
+ }
211
+
212
+ if (action.expected && matchObservation(action.expected, observation).matches) {
213
+ return { actionId, status: 'SUCCEEDED', error: null, observation };
214
+ }
215
+ if (!action.expected && !observation.mismatched && executeResult?.status === 'EXECUTED') {
216
+ return { actionId, status: 'SUCCEEDED', error: null, observation };
217
+ }
218
+
219
+ const explicitlyMissing = observation.exists === false
220
+ || observation.remoteCommit === ''
221
+ || observation.commit === ''
222
+ || observation.published === false;
223
+ if (explicitlyMissing) {
224
+ return {
225
+ actionId,
226
+ status: 'FAILED',
227
+ error: executeError?.message ?? executeResult?.error ?? 'remote state is explicitly missing after execute',
228
+ observation,
229
+ };
230
+ }
231
+
232
+ return {
233
+ actionId,
234
+ status: executeResult?.status === 'EXECUTED' ? 'FAILED' : 'UNCERTAIN',
235
+ error: executeError?.message
236
+ ?? executeResult?.error
237
+ ?? 'observation does not match expected state from frozen plan',
238
+ observation,
239
+ };
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Public API
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Execute a Saga-pattern publish against a frozen, approved release plan.
248
+ *
249
+ * @param {Object} options
250
+ * @param {string} options.planPath - Absolute path to the frozen release plan.
251
+ * @param {string} options.approvalPath - Absolute path to the approval record.
252
+ * @param {Object} options.adapterRegistry - Adapter registry for action execution.
253
+ * @param {string} [options.root] - Project root for baseline capture. Defaults to cwd.
254
+ * @param {string} [options.runDir] - Evidence directory. Defaults to `<planDir>/runs/publish-<ts>`.
255
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
256
+ * @param {(root: string) => Promise<Object>} [options.captureBaselineFn] - Injectable baseline capture.
257
+ *
258
+ * @returns {Promise<{ planPath: string, status: string, checkpoints: Object[] }>}
259
+ *
260
+ * @throws {ReleaseError} GATE_FAILED on any safety gate failure.
261
+ * @throws {ReleaseError} BASELINE_CHANGED if the baseline has changed since freeze.
262
+ */
263
+ export async function publishRelease(options) {
264
+ const {
265
+ planPath,
266
+ approvalPath,
267
+ adapterRegistry,
268
+ root = process.cwd(),
269
+ runDir: runDirOpt,
270
+ clock: clockOpt,
271
+ captureBaselineFn,
272
+ productionMode = false,
273
+ productionConfirmation,
274
+ observePreviousPublicBaselineFn,
275
+ } = options ?? {};
276
+
277
+ const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
278
+ const captureBaselineActual = typeof captureBaselineFn === 'function'
279
+ ? captureBaselineFn
280
+ : captureBaseline;
281
+
282
+ // Load the plan before choosing an evidence authority. A production command
283
+ // must reject an unsafe runDir before writing through it or authorizing any
284
+ // adapter execute.
285
+ let planRaw;
286
+ try {
287
+ planRaw = await readFile(planPath, 'utf8');
288
+ } catch (err) {
289
+ throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
290
+ }
291
+ let plan;
292
+ try {
293
+ plan = JSON.parse(planRaw);
294
+ } catch (err) {
295
+ throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
296
+ }
297
+ validatePlan(plan);
298
+ assertImmutablePlanAuthority(planPath, plan);
299
+ const isProductionPlan = plan.production?.mode === 'github-npm-v1';
300
+
301
+ // --- Set up directories ---
302
+ const runId = `publish-${Date.now()}`;
303
+ let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'publish', runId);
304
+ if (isProductionPlan) {
305
+ runDir = await createProductionRunDir(runDir, planPath);
306
+ } else {
307
+ await mkdir(runDir, { recursive: true });
308
+ }
309
+
310
+ const evidence = createEvidenceWriter({ runDir, command: 'publish', clock: clockFn });
311
+
312
+ try {
313
+ // =======================================================================
314
+ // Safety Gate 1: Load and validate plan schema
315
+ // =======================================================================
316
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-load', status: 'started' });
317
+
318
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-schema', status: 'passed' });
319
+
320
+ // =======================================================================
321
+ // Safety Gate 2: Verify plan digest
322
+ // =======================================================================
323
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'started' });
324
+
325
+ const actualDigest = computePlanDigest(plan);
326
+ if (plan.digest && plan.digest !== actualDigest) {
327
+ throw new ReleaseError(
328
+ GATE_FAILED,
329
+ `plan digest mismatch: expected ${plan.digest.slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
330
+ { expected: plan.digest, actual: actualDigest },
331
+ );
332
+ }
333
+
334
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'passed' });
335
+
336
+ if (productionMode && !isProductionPlan) {
337
+ throw new ReleaseError(GATE_FAILED, 'production publish requires a github-npm-v1 frozen plan');
338
+ }
339
+ if (isProductionPlan) {
340
+ if (!plan.production.assetRoot || plan.production.assetRoot === '.') {
341
+ throw new ReleaseError(GATE_FAILED, 'production plan requires a dedicated assetRoot');
342
+ }
343
+ if (!productionConfirmation || productionConfirmation !== actualDigest) {
344
+ throw new ReleaseError(
345
+ GATE_FAILED,
346
+ 'production confirmation must exactly match the current plan digest',
347
+ { planDigest: actualDigest },
348
+ );
349
+ }
350
+ }
351
+
352
+ // =======================================================================
353
+ // Safety Gate 2b: Validate plan action completeness
354
+ // =======================================================================
355
+ await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'started' });
356
+
357
+ const completenessResult = validatePlanActionCompleteness(plan);
358
+ if (!completenessResult.passed) {
359
+ await evidence.append({
360
+ phase: 'safety-gate',
361
+ gate: 'action-completeness',
362
+ status: 'failed',
363
+ failures: completenessResult.details.failures,
364
+ });
365
+ throw new ReleaseError(
366
+ GATE_FAILED,
367
+ `plan action completeness gate failed: ${completenessResult.details.failures.join('; ')}`,
368
+ { failures: completenessResult.details.failures },
369
+ );
370
+ }
371
+
372
+ await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'passed' });
373
+
374
+ if (isProductionPlan) {
375
+ await evidence.append({ phase: 'safety-gate', gate: 'frozen-artifacts', status: 'started' });
376
+ const assetRoot = await resolveFrozenPath(root, plan.production.assetRoot, 'production asset root');
377
+ for (const unit of plan.units) {
378
+ const frozen = unit.frozenSnapshot;
379
+ const snapshot = await verifyFrozenSnapshot({
380
+ root,
381
+ snapshotPath: frozen.path,
382
+ expectedDigest: frozen.manifestDigest,
383
+ });
384
+ assertInsideAssetRoot(assetRoot, snapshot.snapshotDir, 'frozen snapshot');
385
+ const git = await verifyFrozenGitRepository({
386
+ root,
387
+ gitObjectDir: frozen.gitObjectDir,
388
+ commit: frozen.commit,
389
+ tree: frozen.tree,
390
+ });
391
+ assertInsideAssetRoot(assetRoot, git.gitDir, 'frozen git object directory');
392
+ if (frozen.npm) {
393
+ const tarball = await verifyFrozenFile({
394
+ root,
395
+ filePath: frozen.npm.tarballPath,
396
+ expectedSha256: frozen.npm.tarballSha256,
397
+ label: 'frozen npm tarball',
398
+ });
399
+ assertInsideAssetRoot(assetRoot, tarball.physical, 'frozen npm tarball');
400
+ const npmDistribution = (unit.distributions ?? []).find((item) => item.type === 'npm');
401
+ if (!npmDistribution) {
402
+ throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" has a frozen npm tarball but no npm distribution`);
403
+ }
404
+ await verifyFrozenNpmTarballIdentity({
405
+ package: npmDistribution.package,
406
+ version: unit.targetVersion,
407
+ tarballPath: frozen.npm.tarballPath,
408
+ tarballSha256: frozen.npm.tarballSha256,
409
+ integrity: frozen.npm.integrity,
410
+ }, root);
411
+ }
412
+ }
413
+ await evidence.append({ phase: 'safety-gate', gate: 'frozen-artifacts', status: 'passed' });
414
+ }
415
+
416
+ // =======================================================================
417
+ // Safety Gates 3-7: Load and validate approval record (shared)
418
+ // =======================================================================
419
+ await evidence.append({ phase: 'safety-gate', gate: 'approval-load', status: 'started' });
420
+
421
+ let approvalRaw;
422
+ try {
423
+ approvalRaw = await readFile(approvalPath, 'utf8');
424
+ } catch (err) {
425
+ throw new ReleaseError(
426
+ GATE_FAILED,
427
+ `cannot read approval record: ${err.message}`,
428
+ { approvalPath, cause: err.code },
429
+ );
430
+ }
431
+
432
+ let approval;
433
+ try {
434
+ approval = JSON.parse(approvalRaw);
435
+ } catch (err) {
436
+ throw new ReleaseError(
437
+ GATE_FAILED,
438
+ `approval record is not valid JSON: ${err.message}`,
439
+ { approvalPath },
440
+ );
441
+ }
442
+
443
+ const approvalDigest = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
444
+ ?? computeApprovalDigest(approvalRaw);
445
+
446
+ validateApprovalRecordSchema(approval);
447
+ validateApproval(plan, approval, { clock: clockFn });
448
+
449
+ await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
450
+
451
+ // =======================================================================
452
+ // Safety Gate 8: Action type adapter availability
453
+ // =======================================================================
454
+ await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'started' });
455
+
456
+ for (const action of plan.externalActions) {
457
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
458
+ if (!adapterActionType) {
459
+ // write-remote-identifier and unknown types are handled at checkpoint time
460
+ continue;
461
+ }
462
+ // Verify adapter exists for this action type
463
+ try {
464
+ adapterRegistry.getAdapter(adapterActionType);
465
+ } catch {
466
+ throw new ReleaseError(
467
+ GATE_FAILED,
468
+ `no adapter registered for action type "${adapterActionType}" (plan action "${action.id}")`,
469
+ { actionId: action.id, adapterActionType },
470
+ );
471
+ }
472
+ }
473
+
474
+ await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'passed' });
475
+
476
+ // =======================================================================
477
+ // Safety Gate 9: Baseline comparison
478
+ // =======================================================================
479
+ await evidence.append({ phase: 'safety-gate', gate: 'baseline-check', status: 'started' });
480
+
481
+ if (
482
+ plan.production?.mode === 'github-npm-v1' &&
483
+ plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM
484
+ ) {
485
+ throw new ReleaseError(
486
+ GATE_FAILED,
487
+ `production plan workspace digest algorithm is missing or obsolete; re-run prepare (expected ${WORKSPACE_DIGEST_ALGORITHM})`,
488
+ { expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
489
+ );
490
+ }
491
+
492
+ const currentBaseline = await captureBaselineActual(root);
493
+
494
+ if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
495
+ await evidence.append({
496
+ phase: 'safety-gate',
497
+ gate: 'baseline-check',
498
+ status: 'failed',
499
+ planTreeHash: plan.baseline.gitTreeHash,
500
+ currentTreeHash: currentBaseline.gitTreeHash,
501
+ });
502
+
503
+ // BASELINE_CHANGED: zero adapter execute calls guaranteed
504
+ throw new ReleaseError(
505
+ BASELINE_CHANGED,
506
+ `baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
507
+ { planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
508
+ );
509
+ }
510
+
511
+ if (
512
+ plan.baseline.workspaceDigest &&
513
+ currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
514
+ ) {
515
+ await evidence.append({
516
+ phase: 'safety-gate',
517
+ gate: 'baseline-check',
518
+ status: 'failed',
519
+ planWorkspaceDigest: plan.baseline.workspaceDigest,
520
+ currentWorkspaceDigest: currentBaseline.workspaceDigest,
521
+ });
522
+
523
+ throw new ReleaseError(
524
+ BASELINE_CHANGED,
525
+ `workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
526
+ { planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
527
+ );
528
+ }
529
+
530
+ await evidence.append({
531
+ phase: 'safety-gate',
532
+ gate: 'baseline-check',
533
+ status: 'passed',
534
+ gitTreeHash: currentBaseline.gitTreeHash,
535
+ });
536
+
537
+ // =======================================================================
538
+ // Safety Gate 9b: Per-unit previous public baseline re-observe
539
+ // =======================================================================
540
+ {
541
+ const defaultPpbObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
542
+ try {
543
+ const { execFile: eCb } = await import("node:child_process");
544
+ const { promisify: p } = await import("node:util");
545
+ const ef = p(eCb);
546
+ const host = githubHost || 'github.com';
547
+ const { stdout } = await ef("git", ["ls-remote", `https://${host}/${repo}.git`, ref], {
548
+ shell: false, encoding: "utf8", timeout: 30000,
549
+ });
550
+ const lines = stdout.trim().split("\n").filter(l => l.length > 0);
551
+ if (lines.length === 0) return { status: "drifted", actual: null, diff: "ref not found on remote" };
552
+ const [remoteCommit] = lines[0].split("\t");
553
+ if (remoteCommit === expectedCommit) return { status: "consistent", actual: remoteCommit };
554
+ return { status: "drifted", actual: remoteCommit, diff: "expected " + expectedCommit + ", got " + remoteCommit };
555
+ } catch (err) {
556
+ return { status: "unknown", error: err.message };
557
+ }
558
+ };
559
+ const ppbObserveFn = observePreviousPublicBaselineFn ?? defaultPpbObserveFn;
560
+
561
+ for (const unit of plan.units ?? []) {
562
+ const unitPpb = unit.previousPublicBaseline;
563
+ if (!unitPpb) {
564
+ // Missing baseline on a unit: fail closed in production
565
+ if (isProductionPlan) {
566
+ await evidence.append({
567
+ phase: "safety-gate",
568
+ gate: "previous-public-baseline",
569
+ unitId: unit.id,
570
+ status: "failed",
571
+ error: "missing previousPublicBaseline on unit",
572
+ });
573
+ throw new ReleaseError(
574
+ GATE_FAILED,
575
+ `unit "${unit.id}" missing previousPublicBaseline in plan; cannot proceed`,
576
+ { gate: "previous-public-baseline", unitId: unit.id },
577
+ );
578
+ }
579
+ continue;
580
+ }
581
+
582
+ const githubHost = unit.productionConfig?.githubHost ?? 'github.com';
583
+ assertPreviousPublicBaselineTarget({
584
+ baseline: unitPpb,
585
+ githubHost,
586
+ publicRepo: unit.publicRepo,
587
+ requireHost: isProductionPlan,
588
+ });
589
+
590
+ if (unitPpb.mode === "none") {
591
+ await evidence.append({
592
+ phase: "safety-gate",
593
+ gate: "previous-public-baseline",
594
+ unitId: unit.id,
595
+ status: "passed",
596
+ reason: "fresh repository",
597
+ });
598
+ continue;
599
+ }
600
+
601
+ // Reject unobserved-offline or non-consistent status
602
+ if (unitPpb.status !== "consistent") {
603
+ await evidence.append({
604
+ phase: "safety-gate",
605
+ gate: "previous-public-baseline",
606
+ unitId: unit.id,
607
+ status: "failed",
608
+ unitStatus: unitPpb.status,
609
+ error: `unit "${unit.id}" previous public baseline status is "${unitPpb.status}", expected "consistent"`,
610
+ });
611
+ throw new ReleaseError(
612
+ GATE_FAILED,
613
+ `unit "${unit.id}" previous public baseline not consistent (status: ${unitPpb.status}); all adapter execute blocked`,
614
+ { gate: "previous-public-baseline", unitId: unit.id, unitStatus: unitPpb.status },
615
+ );
616
+ }
617
+
618
+ // Re-observe bound unit
619
+ await evidence.append({
620
+ phase: "safety-gate",
621
+ gate: "previous-public-baseline",
622
+ unitId: unit.id,
623
+ status: "started",
624
+ });
625
+
626
+ const reObserveResult = await reObservePreviousPublicBaseline({
627
+ baseline: unitPpb,
628
+ observeFn: ppbObserveFn,
629
+ evidence,
630
+ });
631
+
632
+ if (!reObserveResult.consistent) {
633
+ await evidence.append({
634
+ phase: "safety-gate",
635
+ gate: "previous-public-baseline",
636
+ unitId: unit.id,
637
+ status: "failed",
638
+ error: reObserveResult.error,
639
+ });
640
+ throw new ReleaseError(
641
+ GATE_FAILED,
642
+ `unit "${unit.id}": ${reObserveResult.error ?? "previous public baseline changed since plan freeze"}`,
643
+ { gate: "previous-public-baseline", unitId: unit.id },
644
+ );
645
+ }
646
+
647
+ await evidence.append({
648
+ phase: "safety-gate",
649
+ gate: "previous-public-baseline",
650
+ unitId: unit.id,
651
+ status: "passed",
652
+ });
653
+ }
654
+ }
655
+
656
+ // =======================================================================
657
+ // All safety gates passed -- prepare for execution
658
+ // =======================================================================
659
+ if (isProductionPlan && plan.status === 'PREPARED') {
660
+ assertTransition('PREPARED', 'APPROVED');
661
+ assertTransition('APPROVED', PUBLISHING);
662
+ } else {
663
+ assertTransition(plan.status, PUBLISHING);
664
+ }
665
+
666
+ // Deep-clone the plan for mutation
667
+ const publishingPlan = deepClone(plan);
668
+ publishingPlan.status = PUBLISHING;
669
+
670
+ // Sort actions by checkpoint order
671
+ const orderedActions = (publishingPlan.externalActions ?? []).slice().sort((a, b) => {
672
+ const ai = CHECKPOINT_ORDER.indexOf(a.type);
673
+ const bi = CHECKPOINT_ORDER.indexOf(b.type);
674
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
675
+ });
676
+
677
+ // =======================================================================
678
+ // Safety Gate 10: Global preflight - validate all actions before any execute
679
+ // =======================================================================
680
+ await evidence.append({ phase: 'safety-gate', gate: 'global-preflight', status: 'started' });
681
+
682
+ for (const action of orderedActions) {
683
+ if (action.type === 'write-remote-identifier') continue;
684
+
685
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
686
+ if (!adapterActionType) continue;
687
+
688
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
689
+ const isMarketplace = MARKETPLACE_TYPES.has(action.type);
690
+ const preflightContext = {
691
+ externalWritesAuthorized: false,
692
+ isolatedConsumerWritesAuthorized: isMarketplace,
693
+ plan: publishingPlan,
694
+ baseline: plan.baseline,
695
+ root,
696
+ runDir,
697
+ };
698
+ const preflightResult = await adapter.preflight(
699
+ { actionType: adapterActionType, ...action.parameters },
700
+ preflightContext,
701
+ );
702
+ if (preflightResult.status === 'PREFLIGHT_FAILED') {
703
+ throw new ReleaseError(
704
+ GATE_FAILED,
705
+ `global preflight failed for action "${action.id}": ${preflightResult.error}`,
706
+ { actionId: action.id, actionType: action.type },
707
+ );
708
+ }
709
+ }
710
+
711
+ await evidence.append({ phase: 'safety-gate', gate: 'global-preflight', status: 'passed' });
712
+
713
+ // =======================================================================
714
+ // Persist an append-only initial state before the first adapter execute.
715
+ // =======================================================================
716
+ const runPath = join(runDir, 'release-run.json');
717
+ const checkpoints = orderedActions.map((action) => ({
718
+ actionId: action.id,
719
+ actionType: action.type,
720
+ status: 'PENDING',
721
+ error: null,
722
+ }));
723
+
724
+ const startedAt = clockFn();
725
+ const buildPersistedState = (status = PUBLISHING, finishedAt) => ({
726
+ runId,
727
+ command: 'publish',
728
+ planDigest: plan.digest,
729
+ planPath,
730
+ approvalDigest,
731
+ approvalPath,
732
+ status,
733
+ checkpoints: checkpoints.map((checkpoint) => ({
734
+ actionId: checkpoint.actionId,
735
+ actionType: checkpoint.actionType,
736
+ status: checkpoint.status === 'SUCCEEDED' ? 'succeeded'
737
+ : checkpoint.status === 'FAILED' ? 'failed'
738
+ : checkpoint.status === 'UNCERTAIN' ? 'uncertain'
739
+ : 'pending',
740
+ ...(checkpoint.error ? { error: { code: 'GATE_FAILED', message: checkpoint.error } } : {}),
741
+ })),
742
+ startedAt,
743
+ ...(finishedAt ? { finishedAt } : {}),
744
+ });
745
+ let stateSequence = 0;
746
+ let latestState = await appendRunState(runDir, stateSequence, buildPersistedState());
747
+
748
+ await evidence.append({
749
+ phase: 'publish',
750
+ status: 'started',
751
+ checkpointCount: orderedActions.length,
752
+ prePersistedRunPath: latestState.statePath,
753
+ });
754
+
755
+ // =======================================================================
756
+ // Execute checkpoints
757
+ // =======================================================================
758
+ let stopped = false;
759
+
760
+ for (let actionIndex = 0; actionIndex < orderedActions.length; actionIndex += 1) {
761
+ const action = orderedActions[actionIndex];
762
+ const checkpoint = checkpoints[actionIndex];
763
+ if (stopped) {
764
+ action.status = 'PENDING';
765
+ continue;
766
+ }
767
+
768
+ // The durable UNCERTAIN state must exist before execute is authorized.
769
+ checkpoint.status = 'UNCERTAIN';
770
+ stateSequence += 1;
771
+ // Once an execute is about to start, this snapshot is itself a
772
+ // reconcile-consumable recovery authority. A process kill after the
773
+ // adapter accepts the write must never leave only PUBLISHING state.
774
+ latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
775
+
776
+ await evidence.append({
777
+ phase: 'checkpoint',
778
+ actionId: action.id,
779
+ actionType: action.type,
780
+ status: 'started',
781
+ });
782
+
783
+ const isMarketplace = MARKETPLACE_TYPES.has(action.type);
784
+ const actionContext = {
785
+ externalWritesAuthorized: !isMarketplace,
786
+ isolatedConsumerWritesAuthorized: isMarketplace,
787
+ plan: publishingPlan,
788
+ baseline: plan.baseline,
789
+ root,
790
+ runDir,
791
+ };
792
+ const result = await executeCheckpoint(action, adapterRegistry, actionContext);
793
+ checkpoint.status = result.status;
794
+ checkpoint.error = result.error;
795
+
796
+ // Update plan action status
797
+ action.status = result.status;
798
+
799
+ await evidence.append({
800
+ phase: 'checkpoint',
801
+ actionId: action.id,
802
+ actionType: action.type,
803
+ status: result.status === 'SUCCEEDED' ? 'completed' : 'failed',
804
+ error: result.error,
805
+ });
806
+
807
+ if (result.status !== 'SUCCEEDED') {
808
+ stopped = true;
809
+ }
810
+
811
+ stateSequence += 1;
812
+ latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
813
+ }
814
+
815
+ // Determine overall status
816
+ const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
817
+ const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED');
818
+
819
+ let overallStatus;
820
+ if (allSucceeded) {
821
+ overallStatus = PUBLISHED;
822
+ publishingPlan.status = PUBLISHED;
823
+ } else if (hasFailure) {
824
+ // Once any execute was attempted, recovery must go through reconcile,
825
+ // even when no success was observed. Re-running publish could duplicate
826
+ // a write accepted just before a transport failure.
827
+ overallStatus = PARTIAL;
828
+ publishingPlan.status = overallStatus;
829
+ } else {
830
+ overallStatus = PUBLISHING;
831
+ publishingPlan.status = PUBLISHING;
832
+ }
833
+
834
+ // Assert valid state transition
835
+ assertTransition(PUBLISHING, publishingPlan.status);
836
+
837
+ await evidence.append({
838
+ phase: 'publish',
839
+ status: 'completed',
840
+ overallStatus,
841
+ checkpointStatuses: checkpoints.map((cp) => cp.status),
842
+ });
843
+
844
+ // Write final run state (runPath already declared in pre-persist section)
845
+ const finishedAt = clockFn();
846
+ stateSequence += 1;
847
+ latestState = await appendRunState(
848
+ runDir,
849
+ stateSequence,
850
+ buildPersistedState(overallStatus, finishedAt),
851
+ );
852
+ const finalRunState = await writeRunAtomic(
853
+ runPath,
854
+ buildPersistedState(overallStatus, finishedAt),
855
+ );
856
+
857
+ await evidence.finish({
858
+ status: overallStatus,
859
+ planPath,
860
+ runPath,
861
+ finalRunDigest: finalRunState.runDigest,
862
+ latestStatePath: latestState.statePath,
863
+ checkpointStatuses: checkpoints.map((cp) => cp.status),
864
+ finishedAt: clockFn(),
865
+ });
866
+
867
+ return { planPath, runPath, status: overallStatus, checkpoints };
868
+ } catch (err) {
869
+ await evidence.append({
870
+ phase: 'publish',
871
+ status: 'failed',
872
+ error: { code: err.code, message: err.message },
873
+ });
874
+
875
+ await evidence.finish({
876
+ status: 'FAILED',
877
+ error: { code: err.code, message: err.message },
878
+ failedAt: clockFn(),
879
+ });
880
+
881
+ throw err;
882
+ }
883
+ }