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