release-skill 0.1.10 → 0.2.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +17 -32
- package/README.zh-CN.md +17 -25
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2721 -1843
- package/package.json +8 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +462 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -736
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +5 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +14 -22
package/src/commands/publish.mjs
CHANGED
|
@@ -41,6 +41,12 @@ import {
|
|
|
41
41
|
reObservePreviousPublicBaseline,
|
|
42
42
|
} from '../core/previous-public-baseline.mjs';
|
|
43
43
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
44
|
+
import {
|
|
45
|
+
ADAPTER_ACTION_TYPE_MAP,
|
|
46
|
+
TIER_TABLE,
|
|
47
|
+
groupActionsByTier,
|
|
48
|
+
sortActionsByCheckpointOrder,
|
|
49
|
+
} from '../core/checkpoints.mjs';
|
|
44
50
|
import { appendRunState, createProductionRunDir, writeRunAtomic, resolveDefaultRunDir } from '../core/run.mjs';
|
|
45
51
|
import {
|
|
46
52
|
ReleaseError,
|
|
@@ -50,6 +56,7 @@ import {
|
|
|
50
56
|
} from '../core/errors.mjs';
|
|
51
57
|
import { assertTransition, PUBLISHING, PUBLISHED, PARTIAL } from '../core/state-machine.mjs';
|
|
52
58
|
import { matchObservation } from '../adapters/contract.mjs';
|
|
59
|
+
import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY, isPropagatingMissing } from '../core/observe-retry.mjs';
|
|
53
60
|
import {
|
|
54
61
|
resolveFrozenPath,
|
|
55
62
|
verifyFrozenFile,
|
|
@@ -71,37 +78,8 @@ function assertInsideAssetRoot(assetRoot, candidate, label) {
|
|
|
71
78
|
|
|
72
79
|
const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
'push-commit',
|
|
77
|
-
'push-snapshot',
|
|
78
|
-
'set-default-branch',
|
|
79
|
-
'create-tag',
|
|
80
|
-
'npm-publish',
|
|
81
|
-
'github-release',
|
|
82
|
-
'claude-marketplace-install',
|
|
83
|
-
'codex-marketplace-install',
|
|
84
|
-
'kimi-marketplace-install',
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Map plan action type to adapter ActionType.
|
|
89
|
-
*
|
|
90
|
-
* Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
|
|
91
|
-
* `github-release`. The adapter contract uses `git-push`, `git-tag`,
|
|
92
|
-
* `npm-publish`, `github-release`.
|
|
93
|
-
*/
|
|
94
|
-
const ADAPTER_ACTION_TYPE_MAP = {
|
|
95
|
-
'push-commit': 'git-push',
|
|
96
|
-
'push-snapshot': 'push-snapshot',
|
|
97
|
-
'set-default-branch': 'set-default-branch',
|
|
98
|
-
'create-tag': 'git-tag',
|
|
99
|
-
'npm-publish': 'npm-publish',
|
|
100
|
-
'github-release': 'github-release',
|
|
101
|
-
'claude-marketplace-install': 'claude-marketplace-install',
|
|
102
|
-
'codex-marketplace-install': 'codex-marketplace-install',
|
|
103
|
-
'kimi-marketplace-install': 'kimi-marketplace-install',
|
|
104
|
-
};
|
|
81
|
+
// CHECKPOINT_ORDER, ADAPTER_ACTION_TYPE_MAP and TIER_TABLE live in
|
|
82
|
+
// ../core/checkpoints.mjs (single source shared with reconcile.mjs; T3.1 §4.7).
|
|
105
83
|
|
|
106
84
|
const MARKETPLACE_TYPES = new Set([
|
|
107
85
|
'claude-marketplace-install',
|
|
@@ -124,6 +102,112 @@ function deepClone(obj) {
|
|
|
124
102
|
return JSON.parse(JSON.stringify(obj));
|
|
125
103
|
}
|
|
126
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Pre-observe classification (T3.3 observe-before-execute)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Four-valued result of classifying a single read-only pre-observe.
|
|
111
|
+
*
|
|
112
|
+
* - CONSISTENT: the remote already satisfies the frozen plan -> SKIPPED.
|
|
113
|
+
* - MISSING: an explicit absence marker -> proceed to execute.
|
|
114
|
+
* - CONFLICTING: present but disagrees with the frozen plan -> fail closed.
|
|
115
|
+
* - UNOBSERVABLE: no usable observation -> proceed to execute (the mandatory
|
|
116
|
+
* post-execute observe remains the safety net).
|
|
117
|
+
*
|
|
118
|
+
* @enum {string}
|
|
119
|
+
*/
|
|
120
|
+
export const PRE_OBSERVE = Object.freeze({
|
|
121
|
+
CONSISTENT: 'CONSISTENT',
|
|
122
|
+
MISSING: 'MISSING',
|
|
123
|
+
CONFLICTING: 'CONFLICTING',
|
|
124
|
+
UNOBSERVABLE: 'UNOBSERVABLE',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** True when `expected` is a non-empty object (vacuous matches are rejected). */
|
|
128
|
+
function hasExpected(expected) {
|
|
129
|
+
return !!expected && typeof expected === 'object' && Object.keys(expected).length > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Classify a single read-only pre-observe result for one action.
|
|
134
|
+
*
|
|
135
|
+
* Shared single source for BOTH preflight layers (the per-checkpoint pre-observe
|
|
136
|
+
* inside executeCheckpoint and the Safety-Gate-10 global preflight arbitration);
|
|
137
|
+
* the two layers must never drift apart (T3.3 §4.6).
|
|
138
|
+
*
|
|
139
|
+
* Classification order is the semantics:
|
|
140
|
+
* 1. UNOBSERVABLE -- no observation, or an empty observation (a thrown observe
|
|
141
|
+
* surfaces as an empty observation with `error`). Fail-safe: the caller
|
|
142
|
+
* proceeds to execute.
|
|
143
|
+
* 2. Double-safe-state actions (push-snapshot, set-default-branch) mirror
|
|
144
|
+
* reconcile's special templates: a "safe pre-state" (frozen predecessor /
|
|
145
|
+
* still-old default branch) is MISSING (execute), NOT CONFLICTING, so a fresh
|
|
146
|
+
* publish is not blown up by generic three-way classification.
|
|
147
|
+
* 3. Generic actions -- CONSISTENT when the observation matches a non-empty
|
|
148
|
+
* expected; MISSING on an explicit absence marker (single-sourced from
|
|
149
|
+
* isPropagatingMissing); otherwise CONFLICTING.
|
|
150
|
+
*
|
|
151
|
+
* Actions with no (or an empty) expected can never be CONSISTENT here; callers
|
|
152
|
+
* skip pre-observe for them entirely (matchObservation({}, obs) would vacuously
|
|
153
|
+
* match and wrongly SKIPPED).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ type?: string, expected?: Object, parameters?: Object }} action
|
|
156
|
+
* @param {{ observation?: Object|null, error?: string|null }|null} observeResult
|
|
157
|
+
* @returns {string} One of PRE_OBSERVE values.
|
|
158
|
+
*/
|
|
159
|
+
export function classifyPreObservation(action, observeResult) {
|
|
160
|
+
const observation = observeResult?.observation;
|
|
161
|
+
// 1. UNOBSERVABLE: missing or empty observation (covers thrown observes, which
|
|
162
|
+
// adapters normalize to an empty observation + error).
|
|
163
|
+
if (!observation || Object.keys(observation).length === 0) {
|
|
164
|
+
return PRE_OBSERVE.UNOBSERVABLE;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const expected = action?.expected;
|
|
168
|
+
const parameters = action?.parameters ?? {};
|
|
169
|
+
|
|
170
|
+
// 2a. push-snapshot (mirrors reconcile.mjs advance-push template).
|
|
171
|
+
if (action?.type === 'push-snapshot') {
|
|
172
|
+
if (hasExpected(expected) && matchObservation(expected, observation).matches) {
|
|
173
|
+
return PRE_OBSERVE.CONSISTENT; // already at the planned successor
|
|
174
|
+
}
|
|
175
|
+
if (parameters.expectedBaselineCommit && observation.commit === parameters.expectedBaselineCommit) {
|
|
176
|
+
return PRE_OBSERVE.MISSING; // still at the frozen predecessor: safe to push
|
|
177
|
+
}
|
|
178
|
+
if (observation.exists === false) {
|
|
179
|
+
return PRE_OBSERVE.MISSING; // remote branch absent: safe to create
|
|
180
|
+
}
|
|
181
|
+
return PRE_OBSERVE.CONFLICTING; // a third-party tip
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 2b. set-default-branch (mirrors reconcile.mjs default-branch template).
|
|
185
|
+
if (action?.type === 'set-default-branch') {
|
|
186
|
+
// Safety floor first: the target branch tip must not have advanced past the
|
|
187
|
+
// frozen commit -- never switch the default branch onto a moved tip.
|
|
188
|
+
if (parameters.expectedNewBranchCommit && observation.newBranchCommit !== parameters.expectedNewBranchCommit) {
|
|
189
|
+
return PRE_OBSERVE.CONFLICTING;
|
|
190
|
+
}
|
|
191
|
+
if (observation.defaultBranch === parameters.newBranch
|
|
192
|
+
&& hasExpected(expected) && matchObservation(expected, observation).matches) {
|
|
193
|
+
return PRE_OBSERVE.CONSISTENT; // already switched
|
|
194
|
+
}
|
|
195
|
+
if (observation.defaultBranch === parameters.oldBranch) {
|
|
196
|
+
return PRE_OBSERVE.MISSING; // not switched yet: safe to execute
|
|
197
|
+
}
|
|
198
|
+
return PRE_OBSERVE.CONFLICTING; // a third branch is the default
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Generic actions (create-tag, npm-publish, github-release, git-push, ...).
|
|
202
|
+
if (hasExpected(expected) && matchObservation(expected, observation).matches) {
|
|
203
|
+
return PRE_OBSERVE.CONSISTENT;
|
|
204
|
+
}
|
|
205
|
+
if (isPropagatingMissing(observeResult)) {
|
|
206
|
+
return PRE_OBSERVE.MISSING;
|
|
207
|
+
}
|
|
208
|
+
return PRE_OBSERVE.CONFLICTING;
|
|
209
|
+
}
|
|
210
|
+
|
|
127
211
|
// ---------------------------------------------------------------------------
|
|
128
212
|
// Checkpoint execution
|
|
129
213
|
// ---------------------------------------------------------------------------
|
|
@@ -163,6 +247,61 @@ async function executeCheckpoint(action, adapterRegistry, context) {
|
|
|
163
247
|
};
|
|
164
248
|
}
|
|
165
249
|
|
|
250
|
+
// T3.3 pre-observe (observe-before-execute): read the remote ONCE, before
|
|
251
|
+
// preflight. A remote already consistent with the frozen plan is SKIPPED
|
|
252
|
+
// (idempotent, zero side effects); an explicit conflict fails closed for human
|
|
253
|
+
// review. Order is the contract: pre-observe -> preflight -> execute, so an
|
|
254
|
+
// "already consistent" remote is never misread by preflight as "occupied".
|
|
255
|
+
// Single-shot on purpose (NOT observeWithRetry): a missing/unobservable
|
|
256
|
+
// pre-observe simply proceeds to execute, whose mandatory post-observe remains
|
|
257
|
+
// the safety net (T3.3 §4.4 asymmetry). Actions without a usable expected are
|
|
258
|
+
// skipped here and keep the legacy preflight -> execute behavior.
|
|
259
|
+
let preObserve;
|
|
260
|
+
if (hasExpected(action.expected)) {
|
|
261
|
+
const preObserveInput = {
|
|
262
|
+
actionType: adapterActionType,
|
|
263
|
+
...action.parameters,
|
|
264
|
+
expected: action.expected,
|
|
265
|
+
};
|
|
266
|
+
let preObserveResult;
|
|
267
|
+
try {
|
|
268
|
+
preObserveResult = await adapter.observe(preObserveInput, context);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
preObserveResult = { observation: null, error: error?.message ?? String(error) };
|
|
271
|
+
}
|
|
272
|
+
const classification = classifyPreObservation(action, preObserveResult);
|
|
273
|
+
await context.evidence?.append?.({
|
|
274
|
+
phase: 'checkpoint-pre-observe',
|
|
275
|
+
actionId,
|
|
276
|
+
actionType: planActionType,
|
|
277
|
+
status: 'pre-observe',
|
|
278
|
+
details: { preObserve: classification },
|
|
279
|
+
});
|
|
280
|
+
if (classification === PRE_OBSERVE.CONSISTENT) {
|
|
281
|
+
return {
|
|
282
|
+
actionId,
|
|
283
|
+
status: 'SKIPPED',
|
|
284
|
+
error: null,
|
|
285
|
+
observation: preObserveResult?.observation ?? null,
|
|
286
|
+
preObserve: PRE_OBSERVE.CONSISTENT,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (classification === PRE_OBSERVE.CONFLICTING) {
|
|
290
|
+
const mismatches = matchObservation(action.expected, preObserveResult?.observation ?? {}).mismatches;
|
|
291
|
+
return {
|
|
292
|
+
actionId,
|
|
293
|
+
status: 'FAILED',
|
|
294
|
+
error: `pre-observe conflict: ${mismatches.join('; ') || 'remote state is present but does not match the frozen plan'}`,
|
|
295
|
+
observation: preObserveResult?.observation ?? null,
|
|
296
|
+
preObserve: PRE_OBSERVE.CONFLICTING,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (classification === PRE_OBSERVE.MISSING) {
|
|
300
|
+
preObserve = PRE_OBSERVE.MISSING;
|
|
301
|
+
}
|
|
302
|
+
// UNOBSERVABLE: `preObserve` stays undefined (left empty, never MISSING).
|
|
303
|
+
}
|
|
304
|
+
|
|
166
305
|
// Preflight (read-only, no authorization required)
|
|
167
306
|
const preflightResult = await adapter.preflight(
|
|
168
307
|
{ actionType: adapterActionType, ...action.parameters },
|
|
@@ -191,18 +330,56 @@ async function executeCheckpoint(action, adapterRegistry, context) {
|
|
|
191
330
|
// Once execute was attempted, its return value is not authoritative: the
|
|
192
331
|
// remote may have accepted the write before the connection failed. Always
|
|
193
332
|
// observe before classifying the checkpoint.
|
|
333
|
+
const observeInput = {
|
|
334
|
+
actionType: adapterActionType,
|
|
335
|
+
...action.parameters,
|
|
336
|
+
expected: action.expected,
|
|
337
|
+
};
|
|
338
|
+
|
|
194
339
|
let observeResult;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
340
|
+
if (executeError) {
|
|
341
|
+
// execute threw: remote state is unknown. Keep the SINGLE observe
|
|
342
|
+
// path and never retry — retrying cannot resolve an unknown write
|
|
343
|
+
// outcome, and must not mask a real conflict. (T1.1 fail-closed.)
|
|
344
|
+
try {
|
|
345
|
+
observeResult = await adapter.observe(observeInput, context);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return {
|
|
348
|
+
actionId,
|
|
349
|
+
status: 'UNCERTAIN',
|
|
350
|
+
error: `execute outcome is uncertain; observe threw: ${error.message}`,
|
|
351
|
+
...(preObserve ? { preObserve } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
// execute did not throw: a missing/uncertain observe may be a
|
|
356
|
+
// transient propagation delay (e.g. npm registry eventual
|
|
357
|
+
// consistency). Retry the read-only observe with bounded backoff
|
|
358
|
+
// (T1.1 PROPAGATING handling). A present-but-mismatched
|
|
359
|
+
// observation (CONFLICTING) is never retried — it is an
|
|
360
|
+
// authoritative conflict that must fail closed for human review.
|
|
361
|
+
const observePolicy = MARKETPLACE_TYPES.has(planActionType)
|
|
362
|
+
? clampPolicyToTimeout(DEFAULT_OBSERVE_RETRY_POLICY, action.parameters?.timeoutMs)
|
|
363
|
+
: DEFAULT_OBSERVE_RETRY_POLICY;
|
|
364
|
+
const retryOutcome = await observeWithRetry({
|
|
365
|
+
observe: (act, ctx) => adapter.observe(act, ctx),
|
|
366
|
+
action: observeInput,
|
|
198
367
|
context,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
368
|
+
policy: observePolicy,
|
|
369
|
+
sleep: context.observeRetrySleep,
|
|
370
|
+
onAttempt: (info) => context.evidence?.append?.({
|
|
371
|
+
phase: 'checkpoint-observe-retry',
|
|
372
|
+
actionId,
|
|
373
|
+
actionType: planActionType,
|
|
374
|
+
attempt: info.attempt,
|
|
375
|
+
maxAttempts: info.maxAttempts,
|
|
376
|
+
missing: info.missing,
|
|
377
|
+
delayMs: info.delayMs,
|
|
378
|
+
status: info.missing ? 'propagating' : 'resolved',
|
|
379
|
+
error: info.error ?? null,
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
observeResult = retryOutcome.result;
|
|
206
383
|
}
|
|
207
384
|
|
|
208
385
|
const observation = observeResult?.observation;
|
|
@@ -211,14 +388,15 @@ async function executeCheckpoint(action, adapterRegistry, context) {
|
|
|
211
388
|
actionId,
|
|
212
389
|
status: 'UNCERTAIN',
|
|
213
390
|
error: `execute outcome is uncertain; observe failed: ${observeResult?.error ?? 'empty observation'}`,
|
|
391
|
+
...(preObserve ? { preObserve } : {}),
|
|
214
392
|
};
|
|
215
393
|
}
|
|
216
394
|
|
|
217
395
|
if (action.expected && matchObservation(action.expected, observation).matches) {
|
|
218
|
-
return { actionId, status: 'SUCCEEDED', error: null, observation };
|
|
396
|
+
return { actionId, status: 'SUCCEEDED', error: null, observation, postObserve: PRE_OBSERVE.CONSISTENT, ...(preObserve ? { preObserve } : {}) };
|
|
219
397
|
}
|
|
220
398
|
if (!action.expected && !observation.mismatched && executeResult?.status === 'EXECUTED') {
|
|
221
|
-
return { actionId, status: 'SUCCEEDED', error: null, observation };
|
|
399
|
+
return { actionId, status: 'SUCCEEDED', error: null, observation, postObserve: PRE_OBSERVE.CONSISTENT, ...(preObserve ? { preObserve } : {}) };
|
|
222
400
|
}
|
|
223
401
|
|
|
224
402
|
const explicitlyMissing = observation.exists === false
|
|
@@ -231,6 +409,8 @@ async function executeCheckpoint(action, adapterRegistry, context) {
|
|
|
231
409
|
status: 'FAILED',
|
|
232
410
|
error: executeError?.message ?? executeResult?.error ?? 'remote state is explicitly missing after execute',
|
|
233
411
|
observation,
|
|
412
|
+
postObserve: PRE_OBSERVE.MISSING,
|
|
413
|
+
...(preObserve ? { preObserve } : {}),
|
|
234
414
|
};
|
|
235
415
|
}
|
|
236
416
|
|
|
@@ -241,6 +421,8 @@ async function executeCheckpoint(action, adapterRegistry, context) {
|
|
|
241
421
|
?? executeResult?.error
|
|
242
422
|
?? 'observation does not match expected state from frozen plan',
|
|
243
423
|
observation,
|
|
424
|
+
postObserve: PRE_OBSERVE.CONFLICTING,
|
|
425
|
+
...(preObserve ? { preObserve } : {}),
|
|
244
426
|
};
|
|
245
427
|
}
|
|
246
428
|
|
|
@@ -277,6 +459,7 @@ export async function publishRelease(options) {
|
|
|
277
459
|
productionMode = false,
|
|
278
460
|
productionConfirmation,
|
|
279
461
|
observePreviousPublicBaselineFn,
|
|
462
|
+
observeRetrySleep,
|
|
280
463
|
} = options ?? {};
|
|
281
464
|
|
|
282
465
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -497,40 +680,71 @@ export async function publishRelease(options) {
|
|
|
497
680
|
|
|
498
681
|
const currentBaseline = await captureBaselineActual(root);
|
|
499
682
|
|
|
683
|
+
// planVersion fork (design: t1-2-digest-decoupling.md §4.3): for v2
|
|
684
|
+
// plans the baseline is record-layer audit data. Drift is recorded as a
|
|
685
|
+
// warning and execution continues -- artifact integrity is sealed by the
|
|
686
|
+
// frozen-artifact re-verification above, never by workspace equality.
|
|
687
|
+
// v1 plans keep the BASELINE_CHANGED hard failure, byte for byte.
|
|
688
|
+
const planV2 = plan.planVersion === 2;
|
|
689
|
+
|
|
500
690
|
if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
691
|
+
if (planV2) {
|
|
692
|
+
await evidence.append({
|
|
693
|
+
phase: 'safety-gate',
|
|
694
|
+
gate: 'baseline-check',
|
|
695
|
+
status: 'warning',
|
|
696
|
+
severity: 'warning',
|
|
697
|
+
reason: 'planVersion 2: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
|
|
698
|
+
planTreeHash: plan.baseline.gitTreeHash,
|
|
699
|
+
currentTreeHash: currentBaseline.gitTreeHash,
|
|
700
|
+
});
|
|
701
|
+
} else {
|
|
702
|
+
await evidence.append({
|
|
703
|
+
phase: 'safety-gate',
|
|
704
|
+
gate: 'baseline-check',
|
|
705
|
+
status: 'failed',
|
|
706
|
+
planTreeHash: plan.baseline.gitTreeHash,
|
|
707
|
+
currentTreeHash: currentBaseline.gitTreeHash,
|
|
708
|
+
});
|
|
508
709
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
710
|
+
// BASELINE_CHANGED: zero adapter execute calls guaranteed
|
|
711
|
+
throw new ReleaseError(
|
|
712
|
+
BASELINE_CHANGED,
|
|
713
|
+
`baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
|
|
714
|
+
{ planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
|
|
715
|
+
);
|
|
716
|
+
}
|
|
515
717
|
}
|
|
516
718
|
|
|
517
719
|
if (
|
|
518
720
|
plan.baseline.workspaceDigest &&
|
|
519
721
|
currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
|
|
520
722
|
) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
723
|
+
if (planV2) {
|
|
724
|
+
await evidence.append({
|
|
725
|
+
phase: 'safety-gate',
|
|
726
|
+
gate: 'baseline-check',
|
|
727
|
+
status: 'warning',
|
|
728
|
+
severity: 'warning',
|
|
729
|
+
reason: 'planVersion 2: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
|
|
730
|
+
planWorkspaceDigest: plan.baseline.workspaceDigest,
|
|
731
|
+
currentWorkspaceDigest: currentBaseline.workspaceDigest,
|
|
732
|
+
});
|
|
733
|
+
} else {
|
|
734
|
+
await evidence.append({
|
|
735
|
+
phase: 'safety-gate',
|
|
736
|
+
gate: 'baseline-check',
|
|
737
|
+
status: 'failed',
|
|
738
|
+
planWorkspaceDigest: plan.baseline.workspaceDigest,
|
|
739
|
+
currentWorkspaceDigest: currentBaseline.workspaceDigest,
|
|
740
|
+
});
|
|
528
741
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
742
|
+
throw new ReleaseError(
|
|
743
|
+
BASELINE_CHANGED,
|
|
744
|
+
`workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
|
|
745
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
|
|
746
|
+
);
|
|
747
|
+
}
|
|
534
748
|
}
|
|
535
749
|
|
|
536
750
|
await evidence.append({
|
|
@@ -673,12 +887,8 @@ export async function publishRelease(options) {
|
|
|
673
887
|
const publishingPlan = deepClone(plan);
|
|
674
888
|
publishingPlan.status = PUBLISHING;
|
|
675
889
|
|
|
676
|
-
// Sort actions by checkpoint order
|
|
677
|
-
const orderedActions = (publishingPlan.externalActions
|
|
678
|
-
const ai = CHECKPOINT_ORDER.indexOf(a.type);
|
|
679
|
-
const bi = CHECKPOINT_ORDER.indexOf(b.type);
|
|
680
|
-
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
681
|
-
});
|
|
890
|
+
// Sort actions by checkpoint order (shared single source, T3.1 §4.7).
|
|
891
|
+
const orderedActions = sortActionsByCheckpointOrder(publishingPlan.externalActions);
|
|
682
892
|
|
|
683
893
|
// =======================================================================
|
|
684
894
|
// Safety Gate 10: Global preflight - validate all actions before any execute
|
|
@@ -706,6 +916,39 @@ export async function publishRelease(options) {
|
|
|
706
916
|
preflightContext,
|
|
707
917
|
);
|
|
708
918
|
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
919
|
+
// T3.3 §4.6: an "occupied" preflight failure overlaps semantically with
|
|
920
|
+
// "already consistent". For NON-marketplace action types, arbitrate with
|
|
921
|
+
// a single read-only pre-observe using the SAME classifier as the
|
|
922
|
+
// per-checkpoint path: CONSISTENT means the remote already satisfies the
|
|
923
|
+
// frozen plan, so let the action through to its SKIPPED path instead of
|
|
924
|
+
// failing the whole gate. Anything else (MISSING/CONFLICTING/UNOBSERVABLE)
|
|
925
|
+
// keeps the fail-closed GATE_FAILED. Marketplace preflights are local
|
|
926
|
+
// integrity checks, never remote occupation, so they are NEVER arbitrated
|
|
927
|
+
// -- a CONSISTENT observation must not bypass their completeness gate.
|
|
928
|
+
if (!isMarketplace && hasExpected(action.expected)) {
|
|
929
|
+
let arbitration;
|
|
930
|
+
try {
|
|
931
|
+
arbitration = await adapter.observe(
|
|
932
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
933
|
+
preflightContext,
|
|
934
|
+
);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
arbitration = { observation: null, error: error?.message ?? String(error) };
|
|
937
|
+
}
|
|
938
|
+
const verdict = classifyPreObservation(action, arbitration);
|
|
939
|
+
await evidence.append({
|
|
940
|
+
phase: 'global-preflight-arbitration',
|
|
941
|
+
actionId: action.id,
|
|
942
|
+
actionType: action.type,
|
|
943
|
+
status: 'pre-observe',
|
|
944
|
+
details: { preObserve: verdict, preflightError: preflightResult.error ?? null },
|
|
945
|
+
});
|
|
946
|
+
if (verdict === PRE_OBSERVE.CONSISTENT) {
|
|
947
|
+
// Remote already consistent: the action takes the SKIPPED path inside
|
|
948
|
+
// executeCheckpoint; do not fail the gate.
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
709
952
|
throw new ReleaseError(
|
|
710
953
|
GATE_FAILED,
|
|
711
954
|
`global preflight failed for action "${action.id}": ${preflightResult.error}`,
|
|
@@ -742,7 +985,10 @@ export async function publishRelease(options) {
|
|
|
742
985
|
status: checkpoint.status === 'SUCCEEDED' ? 'succeeded'
|
|
743
986
|
: checkpoint.status === 'FAILED' ? 'failed'
|
|
744
987
|
: checkpoint.status === 'UNCERTAIN' ? 'uncertain'
|
|
988
|
+
: checkpoint.status === 'SKIPPED' ? 'skipped'
|
|
745
989
|
: 'pending',
|
|
990
|
+
...(checkpoint.preObserve ? { preObserve: checkpoint.preObserve } : {}),
|
|
991
|
+
...(checkpoint.postObserve ? { postObserve: checkpoint.postObserve } : {}),
|
|
746
992
|
...(checkpoint.error ? { error: { code: 'GATE_FAILED', message: checkpoint.error } } : {}),
|
|
747
993
|
})),
|
|
748
994
|
startedAt,
|
|
@@ -759,70 +1005,137 @@ export async function publishRelease(options) {
|
|
|
759
1005
|
});
|
|
760
1006
|
|
|
761
1007
|
// =======================================================================
|
|
762
|
-
// Execute checkpoints
|
|
1008
|
+
// Execute checkpoints (dependency-tiered; T3.1).
|
|
1009
|
+
//
|
|
1010
|
+
// Tiers run strictly serially; the actions inside a tier are independent
|
|
1011
|
+
// and run concurrently with Promise.allSettled semantics -- no fail-fast
|
|
1012
|
+
// short-circuit, because a sibling's success and observation must never be
|
|
1013
|
+
// dropped (observations are reconcile's recovery authority). State is
|
|
1014
|
+
// snapshotted once per tier boundary instead of once per checkpoint:
|
|
1015
|
+
// * tier start: every action in the tier is set UNCERTAIN and one
|
|
1016
|
+
// appendRunState persists that durable pre-execute authority;
|
|
1017
|
+
// * tier end: all of the tier's results are folded in and one
|
|
1018
|
+
// appendRunState persists them.
|
|
1019
|
+
// Crash-recovery semantics: a kill inside a tier leaves that tier's actions
|
|
1020
|
+
// at UNCERTAIN in the last snapshot; reconcile rebuilds actual state per
|
|
1021
|
+
// actionId from that snapshot plus observe (it never depended on execution
|
|
1022
|
+
// order). appendRunState's seq chain (run.mjs) is unchanged.
|
|
763
1023
|
// =======================================================================
|
|
1024
|
+
const checkpointByActionId = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
|
|
1025
|
+
const { tiers, unknown } = groupActionsByTier(orderedActions);
|
|
764
1026
|
let stopped = false;
|
|
765
1027
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1028
|
+
// Fail closed on any action type the tier table does not recognize. Such a
|
|
1029
|
+
// type is never silently appended to the last tier: an unrecognized external
|
|
1030
|
+
// write must stop the saga for human review. (Validated plans cannot contain
|
|
1031
|
+
// such a type -- the plan schema enumerates exactly the tier-table types --
|
|
1032
|
+
// so this is defense in depth, not a reachable path.)
|
|
1033
|
+
if (unknown.length > 0) {
|
|
1034
|
+
for (const action of unknown) {
|
|
1035
|
+
const checkpoint = checkpointByActionId.get(action.id);
|
|
1036
|
+
checkpoint.status = 'FAILED';
|
|
1037
|
+
checkpoint.error = `Unknown action type not present in the dependency tier table: ${action.type}`;
|
|
1038
|
+
action.status = 'FAILED';
|
|
1039
|
+
await evidence.append({
|
|
1040
|
+
phase: 'checkpoint',
|
|
1041
|
+
actionId: action.id,
|
|
1042
|
+
actionType: action.type,
|
|
1043
|
+
status: 'failed',
|
|
1044
|
+
error: checkpoint.error,
|
|
1045
|
+
});
|
|
772
1046
|
}
|
|
773
|
-
|
|
774
|
-
// The durable UNCERTAIN state must exist before execute is authorized.
|
|
775
|
-
checkpoint.status = 'UNCERTAIN';
|
|
1047
|
+
stopped = true;
|
|
776
1048
|
stateSequence += 1;
|
|
777
|
-
// Once an execute is about to start, this snapshot is itself a
|
|
778
|
-
// reconcile-consumable recovery authority. A process kill after the
|
|
779
|
-
// adapter accepts the write must never leave only PUBLISHING state.
|
|
780
1049
|
latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
|
|
1050
|
+
}
|
|
781
1051
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
await evidence.append({
|
|
806
|
-
phase: 'checkpoint',
|
|
807
|
-
actionId: action.id,
|
|
808
|
-
actionType: action.type,
|
|
809
|
-
status: result.status === 'SUCCEEDED' ? 'completed' : 'failed',
|
|
810
|
-
error: result.error,
|
|
811
|
-
});
|
|
1052
|
+
for (let tierIndex = 0; tierIndex < tiers.length && !stopped; tierIndex += 1) {
|
|
1053
|
+
const tierActions = tiers[tierIndex];
|
|
1054
|
+
if (tierActions.length === 0) continue;
|
|
1055
|
+
const tierCheckpoints = tierActions.map((action) => checkpointByActionId.get(action.id));
|
|
1056
|
+
|
|
1057
|
+
// --- Tier start: persist every action in this tier as UNCERTAIN in a
|
|
1058
|
+
// single snapshot BEFORE any execute is authorized. Once an execute is
|
|
1059
|
+
// about to start, this snapshot is itself a reconcile-consumable recovery
|
|
1060
|
+
// authority; a process kill after an adapter accepts a write must never
|
|
1061
|
+
// leave only PUBLISHING state.
|
|
1062
|
+
for (let i = 0; i < tierActions.length; i += 1) {
|
|
1063
|
+
tierCheckpoints[i].status = 'UNCERTAIN';
|
|
1064
|
+
await evidence.append({
|
|
1065
|
+
phase: 'checkpoint',
|
|
1066
|
+
actionId: tierActions[i].id,
|
|
1067
|
+
actionType: tierActions[i].type,
|
|
1068
|
+
status: 'started',
|
|
1069
|
+
details: { tier: tierIndex },
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
stateSequence += 1;
|
|
1073
|
+
latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
|
|
812
1074
|
|
|
813
|
-
|
|
814
|
-
|
|
1075
|
+
// --- Tier execute: concurrent, allSettled semantics (no fail-fast).
|
|
1076
|
+
const results = await Promise.all(tierActions.map(async (action) => {
|
|
1077
|
+
const isMarketplace = MARKETPLACE_TYPES.has(action.type);
|
|
1078
|
+
const actionContext = {
|
|
1079
|
+
externalWritesAuthorized: !isMarketplace,
|
|
1080
|
+
isolatedConsumerWritesAuthorized: isMarketplace,
|
|
1081
|
+
plan: publishingPlan,
|
|
1082
|
+
baseline: plan.baseline,
|
|
1083
|
+
root,
|
|
1084
|
+
runDir,
|
|
1085
|
+
evidence,
|
|
1086
|
+
observeRetrySleep,
|
|
1087
|
+
};
|
|
1088
|
+
try {
|
|
1089
|
+
return await executeCheckpoint(action, adapterRegistry, actionContext);
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
// executeCheckpoint classifies failures rather than throwing; a throw
|
|
1092
|
+
// is still an uncertain outcome and must never drop the action from
|
|
1093
|
+
// the tier snapshot.
|
|
1094
|
+
return { actionId: action.id, status: 'UNCERTAIN', error: `checkpoint threw: ${error.message}` };
|
|
1095
|
+
}
|
|
1096
|
+
}));
|
|
1097
|
+
|
|
1098
|
+
// --- Tier settle: fold every result into the snapshot, successes and
|
|
1099
|
+
// failures alike (a failed sibling must not discard a success).
|
|
1100
|
+
let tierFailed = false;
|
|
1101
|
+
for (let i = 0; i < tierActions.length; i += 1) {
|
|
1102
|
+
const action = tierActions[i];
|
|
1103
|
+
const checkpoint = tierCheckpoints[i];
|
|
1104
|
+
const result = results[i];
|
|
1105
|
+
checkpoint.status = result.status;
|
|
1106
|
+
checkpoint.error = result.error;
|
|
1107
|
+
if (result.preObserve) checkpoint.preObserve = result.preObserve;
|
|
1108
|
+
if (result.postObserve) checkpoint.postObserve = result.postObserve;
|
|
1109
|
+
action.status = result.status;
|
|
1110
|
+
await evidence.append({
|
|
1111
|
+
phase: 'checkpoint',
|
|
1112
|
+
actionId: action.id,
|
|
1113
|
+
actionType: action.type,
|
|
1114
|
+
status: result.status === 'SUCCEEDED' || result.status === 'SKIPPED' ? 'completed' : 'failed',
|
|
1115
|
+
error: result.error,
|
|
1116
|
+
details: { tier: tierIndex, ...(result.status === 'SKIPPED' ? { skipped: true } : {}) },
|
|
1117
|
+
});
|
|
1118
|
+
// SKIPPED is a success (the remote was already consistent); it must not
|
|
1119
|
+
// stop the saga. Only FAILED/UNCERTAIN fail the tier.
|
|
1120
|
+
if (result.status !== 'SUCCEEDED' && result.status !== 'SKIPPED') {
|
|
1121
|
+
tierFailed = true;
|
|
1122
|
+
}
|
|
815
1123
|
}
|
|
816
1124
|
|
|
1125
|
+
// --- Tier end: one snapshot carrying the whole tier's results.
|
|
817
1126
|
stateSequence += 1;
|
|
818
1127
|
latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
|
|
1128
|
+
|
|
1129
|
+
if (tierFailed) {
|
|
1130
|
+
stopped = true;
|
|
1131
|
+
}
|
|
819
1132
|
}
|
|
820
1133
|
|
|
821
1134
|
// Close the push -> default-branch TOCTOU window with a final read-only
|
|
822
1135
|
// consistency pass. Both the branch tip and default-branch name are bound
|
|
823
1136
|
// in the frozen action expectations. A late change keeps the saga PARTIAL
|
|
824
1137
|
// and must be resolved by reconcile/human review.
|
|
825
|
-
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
1138
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
|
|
826
1139
|
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
|
|
827
1140
|
for (let index = 0; index < orderedActions.length; index += 1) {
|
|
828
1141
|
const action = orderedActions[index];
|
|
@@ -856,14 +1169,17 @@ export async function publishRelease(options) {
|
|
|
856
1169
|
break;
|
|
857
1170
|
}
|
|
858
1171
|
}
|
|
859
|
-
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
1172
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
|
|
860
1173
|
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
|
|
861
1174
|
}
|
|
862
1175
|
}
|
|
863
1176
|
|
|
864
1177
|
// Determine overall status
|
|
865
1178
|
const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
|
|
866
|
-
|
|
1179
|
+
// SKIPPED counts as success: the remote was already consistent with the
|
|
1180
|
+
// frozen plan, so an all-SKIPPED/SUCCEEDED run is a clean PUBLISHED (the
|
|
1181
|
+
// idempotent ideal -- zero writes were even needed).
|
|
1182
|
+
const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED');
|
|
867
1183
|
|
|
868
1184
|
let overallStatus;
|
|
869
1185
|
if (allSucceeded) {
|