release-skill 0.8.1 → 0.9.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/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +14 -2
- package/INSTALL.zh-CN.md +12 -2
- package/README.md +28 -16
- package/README.zh-CN.md +23 -14
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/claude/bin/release-skill.bundle.mjs +2416 -1320
- package/adapters/claude/schemas/release-plan.schema.json +40 -3
- package/adapters/claude/skills/release-finish/SKILL.md +65 -0
- package/adapters/claude/skills/release-verify/SKILL.md +6 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/codex/bin/release-skill.bundle.mjs +2416 -1320
- package/adapters/codex/schemas/release-plan.schema.json +40 -3
- package/adapters/codex/skills/release-finish/SKILL.md +72 -0
- package/adapters/codex/skills/release-verify/SKILL.md +6 -1
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/kimi/bin/release-skill.bundle.mjs +2416 -1320
- package/adapters/kimi/schemas/release-plan.schema.json +40 -3
- package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
- package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +2416 -1320
- package/adapters/workbuddy/schemas/release-plan.schema.json +40 -3
- package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
- package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
- package/bin/release-skill-cli.mjs +126 -1
- package/bin/release-skill-local-finish.mjs +4 -0
- package/bin/release-skill.bundle.mjs +2416 -1320
- package/package.json +1 -1
- package/platform-manifest.json +30 -4
- package/schemas/release-plan.schema.json +40 -3
- package/skills/release-finish/SKILL.md +65 -0
- package/skills/release-verify/SKILL.md +6 -1
- package/skills-src/release-finish/SKILL.md +65 -0
- package/skills-src/release-verify/SKILL.md +6 -1
- package/src/commands/approve.mjs +14 -2
- package/src/commands/distribute.mjs +562 -321
- package/src/commands/post-release-local.mjs +611 -0
- package/src/commands/postverify.mjs +262 -155
- package/src/commands/prepare.mjs +67 -52
- package/src/commands/publish.mjs +11 -10
- package/src/commands/reconcile.mjs +11 -10
- package/src/commands/setup.mjs +26 -13
- package/src/commands/ship.mjs +45 -12
- package/src/commands/verify.mjs +19 -8
- package/src/core/adoption-assessment.mjs +0 -3
- package/src/core/approval.mjs +11 -8
- package/src/core/hooks.mjs +0 -16
- package/src/core/plan.mjs +19 -11
- package/src/core/postpublish-approval.mjs +12 -2
- package/src/core/postpublish-bundle.mjs +51 -13
- package/src/core/postpublish.mjs +146 -6
- package/src/core/preset-executor.mjs +53 -0
- package/src/core/proposal-inbox.mjs +91 -27
- package/src/core/recovery.mjs +27 -12
- package/src/producers/build-adapters.mjs +10 -1
package/src/core/hooks.mjs
CHANGED
|
@@ -43,22 +43,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
|
43
43
|
// Validation
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* A release plan binds at most one postPublish declaration. Both prepare
|
|
48
|
-
* and adoption assessment consume this pure rule and retain their own
|
|
49
|
-
* error codes and presentation. Declaration contents are validated by
|
|
50
|
-
* the existing postPublish validator, not by this cross-unit check.
|
|
51
|
-
*
|
|
52
|
-
* @param {Array<Object>} units - config.releaseUnits.
|
|
53
|
-
* @returns {null|{unitIds: string[]}} Sorted conflicting unit IDs, or null.
|
|
54
|
-
*/
|
|
55
|
-
export function findPostPublishUnitConflict(units) {
|
|
56
|
-
const declaring = (units ?? [])
|
|
57
|
-
.filter((unit) => unit && typeof unit === 'object' && unit.postPublish !== undefined);
|
|
58
|
-
if (declaring.length <= 1) return null;
|
|
59
|
-
return { unitIds: declaring.map((unit) => unit.id).sort() };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
46
|
/**
|
|
63
47
|
* Validate the shape of a hook descriptor against the project contract.
|
|
64
48
|
*
|
package/src/core/plan.mjs
CHANGED
|
@@ -202,7 +202,8 @@ export function validatePlan(plan) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1
|
|
205
|
+
* planVersion 2/3 digest scope (design: t1-2-digest-decoupling.md §4.1;
|
|
206
|
+
* v3 inherits the v2 scope per 多发布单元 postPublish v3 §4.2).
|
|
206
207
|
*
|
|
207
208
|
* The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
|
|
208
209
|
* the whole `baseline` object, and each action's runtime `status` -- is
|
|
@@ -210,8 +211,10 @@ export function validatePlan(plan) {
|
|
|
210
211
|
* but is excluded from the digest. Everything else (planVersion, units with
|
|
211
212
|
* every frozenSnapshot artifact identity field, externalActions identity,
|
|
212
213
|
* production, configDigest, verificationGates, snapshotDigest) binds.
|
|
214
|
+
* For v3 the frozen `postPublish` array and every item binding field stay in
|
|
215
|
+
* the binding layer: any order or binding change moves the digest.
|
|
213
216
|
*
|
|
214
|
-
* This is the ONLY place the v2 record layer is defined.
|
|
217
|
+
* This is the ONLY place the v2/v3 record layer is defined.
|
|
215
218
|
*
|
|
216
219
|
* @param {object} plan - A plan object.
|
|
217
220
|
* @returns {object} The binding-layer projection of the plan.
|
|
@@ -242,9 +245,12 @@ function stripRecordLayerV2(plan) {
|
|
|
242
245
|
*
|
|
243
246
|
* Digest scope forks on `plan.planVersion` (centralized here, never spread
|
|
244
247
|
* elsewhere):
|
|
245
|
-
* - planVersion 2: record-layer fields (status/createdAt/baseline/action
|
|
248
|
+
* - planVersion 2/3: record-layer fields (status/createdAt/baseline/action
|
|
246
249
|
* status/digest) are stripped before hashing, so re-preparing over the
|
|
247
|
-
* same sources yields the same digest. See `stripRecordLayerV2`.
|
|
250
|
+
* same sources yields the same digest. See `stripRecordLayerV2`. v3
|
|
251
|
+
* inherits the v2 scope exactly (multi-release-unit postPublish v3 §4.2);
|
|
252
|
+
* versions beyond {2,3} are NOT admitted and stay fail-closed on the
|
|
253
|
+
* legacy v1 path rather than being silently bound to v2 semantics.
|
|
248
254
|
* - planVersion 1 or absent (legacy): only the top-level `digest` field is
|
|
249
255
|
* stripped. Byte-for-byte legacy semantics -- this path must never change.
|
|
250
256
|
*
|
|
@@ -258,7 +264,7 @@ export function computePlanDigest(plan) {
|
|
|
258
264
|
// 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
|
|
259
265
|
// 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
|
|
260
266
|
// 完全一致,对含 undefined 的计划保持迁移前字节语义。
|
|
261
|
-
if (plan.planVersion === 2) {
|
|
267
|
+
if (plan.planVersion === 2 || plan.planVersion === 3) {
|
|
262
268
|
return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
|
|
263
269
|
}
|
|
264
270
|
// Strip the digest field if present so the hash is self-consistent.
|
|
@@ -341,14 +347,15 @@ export async function writePlanImmutable(planPath, plan) {
|
|
|
341
347
|
// EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
|
|
342
348
|
const existing = await readFile(planPath, 'utf8').catch(() => null);
|
|
343
349
|
if (existing === json) return { planPath, planDigest };
|
|
344
|
-
// planVersion 2 (design: t1-2-digest-decoupling.md §4.1
|
|
345
|
-
//
|
|
350
|
+
// planVersion 2/3 (design: t1-2-digest-decoupling.md §4.1; v3 inherits
|
|
351
|
+
// the scope per 多发布单元 postPublish v3 §4.2): record-layer fields
|
|
352
|
+
// (status/createdAt/baseline) are excluded from the digest, so
|
|
346
353
|
// re-preparing over identical binding content yields the SAME digest
|
|
347
354
|
// with legitimately different bytes. Reuse the existing authority IFF
|
|
348
355
|
// it is genuinely self-consistent (its embedded digest and recomputed
|
|
349
356
|
// digest both equal this digest) -- the authority is never replaced.
|
|
350
357
|
// Any other divergence (tampering, malformed file) still fails closed.
|
|
351
|
-
if (plan.planVersion === 2 && existing !== null) {
|
|
358
|
+
if ((plan.planVersion === 2 || plan.planVersion === 3) && existing !== null) {
|
|
352
359
|
let existingPlan = null;
|
|
353
360
|
try {
|
|
354
361
|
existingPlan = JSON.parse(existing);
|
|
@@ -386,7 +393,7 @@ export async function writePlanImmutable(planPath, plan) {
|
|
|
386
393
|
/**
|
|
387
394
|
* Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
|
|
388
395
|
* Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
|
|
389
|
-
* schema; used by the planVersion 2 completeness gate, which requires a
|
|
396
|
+
* schema; used by the planVersion 2/3 completeness gate, which requires a
|
|
390
397
|
* canonical normalized timestamp without re-binding it to plan.createdAt.
|
|
391
398
|
*/
|
|
392
399
|
const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
|
|
@@ -568,8 +575,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
|
|
|
568
575
|
}
|
|
569
576
|
if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
|
|
570
577
|
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
|
|
571
|
-
} else if (plan.planVersion === 2) {
|
|
572
|
-
// planVersion 2 (design: t1-2-digest-decoupling.md §4.2
|
|
578
|
+
} else if (plan.planVersion === 2 || plan.planVersion === 3) {
|
|
579
|
+
// planVersion 2/3 (design: t1-2-digest-decoupling.md §4.2; v3 inherits
|
|
580
|
+
// the semantics per 多发布单元 postPublish v3 §4.2): the freeze
|
|
573
581
|
// timestamp is derived from the headCommit committer date, so it no
|
|
574
582
|
// longer equals plan.createdAt (record-layer real clock). It must
|
|
575
583
|
// still be a canonical normalized timestamp -- the same pattern the
|
|
@@ -47,6 +47,7 @@ import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
|
47
47
|
import { computePlanDigest } from './plan.mjs';
|
|
48
48
|
import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
|
|
49
49
|
import { resolvePresetRequiresApproval } from './presets.mjs';
|
|
50
|
+
import { normalizePostPublishView, validatePostPublishHookIdUniqueness } from './postpublish.mjs';
|
|
50
51
|
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
51
52
|
|
|
52
53
|
const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
|
|
@@ -79,7 +80,8 @@ export function validatePostPublishApprovalRecordSchema(approval) {
|
|
|
79
80
|
* - record shape (postpublish-approval-record schema; additionalProperties
|
|
80
81
|
* is false, so plan-level fields like approvedActions are rejected);
|
|
81
82
|
* - planDigest equals the computed plan digest;
|
|
82
|
-
* - hookId names a hook declared in the frozen plan's postPublish
|
|
83
|
+
* - hookId names a hook declared in the frozen plan's postPublish
|
|
84
|
+
* declarations (unified normalized view, globally unique across units);
|
|
83
85
|
* - that hook actually declares requiresApproval: true;
|
|
84
86
|
* - the shared approval time window (24h max, 5-minute skew, unexpired).
|
|
85
87
|
*
|
|
@@ -103,7 +105,15 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
|
|
|
103
105
|
);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
// §4.3 unified normalization (rework R-02): the frozen plan's postPublish
|
|
109
|
+
// may be the legacy single declaration (planVersion 1/2) or the v3
|
|
110
|
+
// declaration array; every consumer resolves declared hooks through the
|
|
111
|
+
// SAME normalized view, and the SAME array-level uniqueness authority
|
|
112
|
+
// re-asserts that hookId is globally unique across declarations — one
|
|
113
|
+
// (planDigest, hookId) approval must never authorize two units.
|
|
114
|
+
const declarations = normalizePostPublishView(plan);
|
|
115
|
+
validatePostPublishHookIdUniqueness(declarations);
|
|
116
|
+
const hooks = declarations.flatMap((declaration) => declaration.hooks ?? []);
|
|
107
117
|
const hook = hooks.find((entry) => entry.id === approval.hookId);
|
|
108
118
|
if (!hook) {
|
|
109
119
|
throw new ReleaseError(
|
|
@@ -244,28 +244,38 @@ function sameClosureResources(actual, expected) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
247
|
+
* Verify the frozen execution bundle closure WITHOUT installing anything
|
|
248
|
+
* (rework R-01: the full-declaration preflight reuses this exact
|
|
249
|
+
* implementation before the first external write — a later declaration's
|
|
250
|
+
* missing/drifted bundle must fail the whole saga with zero writes).
|
|
249
251
|
*
|
|
250
|
-
* Steps:
|
|
252
|
+
* Steps (the first half of verifyAndInstallExecutionBundle, shared verbatim):
|
|
251
253
|
* 1. Strictly read every frozen resource from the digest-addressed store
|
|
252
254
|
* (`readFileStrict` with the frozen sha256 content guard); the file mode
|
|
253
255
|
* must still equal the publication mode — any deviation is tamper;
|
|
254
256
|
* 2. recompute the closure through Foundation in a disposable workspace and
|
|
255
|
-
* compare it (digest + resources) with the plan's frozen closure
|
|
256
|
-
* 3. exclusively publish the verified bytes into the worktree — an occupied
|
|
257
|
-
* target means the bundle would shadow a frozen tag file and fails closed.
|
|
257
|
+
* compare it (digest + resources) with the plan's frozen closure.
|
|
258
258
|
*
|
|
259
259
|
* @param {object} params
|
|
260
|
-
* @param {object} params.
|
|
260
|
+
* @param {object} params.postPublish - The CURRENT declaration being
|
|
261
|
+
* verified (REQUIRED, rework R-06: the v1/v2 normalized single item or the
|
|
262
|
+
* v3 loop item; an array fails closed).
|
|
261
263
|
* @param {string} params.planPath - Absolute plan authority path (mechanical
|
|
262
264
|
* bundle-root anchor).
|
|
263
|
-
* @
|
|
264
|
-
*
|
|
265
|
+
* @returns {Promise<{verified: boolean, bytesByPath: Map<string, Buffer>}>}
|
|
266
|
+
* `verified: false` with an empty map when the declaration carries no
|
|
267
|
+
* executionBundle (a legal no-bundle declaration).
|
|
265
268
|
*/
|
|
266
|
-
export async function
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
269
|
+
export async function verifyExecutionBundle({ planPath, postPublish } = {}) {
|
|
270
|
+
const declaration = postPublish;
|
|
271
|
+
if (!declaration || typeof declaration !== 'object') {
|
|
272
|
+
fail('verifyExecutionBundle requires the current postPublish declaration; pass the normalized single item or the v3 loop item explicitly');
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(declaration)) {
|
|
275
|
+
fail('verifyExecutionBundle requires the current postPublish declaration, never the declaration array');
|
|
276
|
+
}
|
|
277
|
+
const bundle = declaration.executionBundle;
|
|
278
|
+
if (!bundle) return { verified: false, bytesByPath: new Map() };
|
|
269
279
|
const closure = bundle.closure;
|
|
270
280
|
if (!closure || !Array.isArray(closure.resources) || typeof closure.digest !== 'string') {
|
|
271
281
|
fail('plan carries a malformed executionBundle closure');
|
|
@@ -318,10 +328,38 @@ export async function verifyAndInstallExecutionBundle({ plan, planPath, worktree
|
|
|
318
328
|
fail(`bundle closure recomputation failed: ${cause?.message ?? cause}`, { kind: cause?.details?.kind });
|
|
319
329
|
}
|
|
320
330
|
|
|
331
|
+
return { verified: true, bytesByPath };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Re-verify the frozen execution bundle (the SAME closure verification the
|
|
336
|
+
* full-declaration preflight runs) and install ONLY the verified bytes into
|
|
337
|
+
* the detached tag worktree. Runs BEFORE any hook or external write.
|
|
338
|
+
*
|
|
339
|
+
* Install step: exclusively publish the verified bytes into the worktree —
|
|
340
|
+
* an occupied target means the bundle would shadow a frozen tag file and
|
|
341
|
+
* fails closed.
|
|
342
|
+
*
|
|
343
|
+
* @param {object} params
|
|
344
|
+
* @param {object} params.postPublish - The CURRENT declaration being
|
|
345
|
+
* executed (v3 multi-unit contract §4.3). REQUIRED (rework R-06): callers
|
|
346
|
+
* must pass the declaration explicitly — v1/v2 callers pass the single item
|
|
347
|
+
* obtained from normalizePostPublishView(), v3 callers pass their loop
|
|
348
|
+
* item. Omitting it fails closed; an array never matches (fail-closed).
|
|
349
|
+
* @param {string} params.planPath - Absolute plan authority path (mechanical
|
|
350
|
+
* bundle-root anchor).
|
|
351
|
+
* @param {string} params.worktreePath - Detached tag worktree root.
|
|
352
|
+
* @returns {Promise<{installed: string[]}>} Installed relative paths.
|
|
353
|
+
*/
|
|
354
|
+
export async function verifyAndInstallExecutionBundle({ planPath, worktreePath, postPublish } = {}) {
|
|
355
|
+
const { bytesByPath } = await verifyExecutionBundle({ planPath, postPublish });
|
|
356
|
+
const declaration = postPublish;
|
|
357
|
+
const closure = declaration?.executionBundle?.closure;
|
|
358
|
+
|
|
321
359
|
// Install verified bytes into the tag worktree — exclusively: the bundle
|
|
322
360
|
// must never overwrite (shadow) a file that already belongs to the tag.
|
|
323
361
|
const installed = [];
|
|
324
|
-
for (const resource of closure
|
|
362
|
+
for (const resource of closure?.resources ?? []) {
|
|
325
363
|
try {
|
|
326
364
|
await publishFileExclusive(worktreePath, resource.path, bytesByPath.get(resource.path), {
|
|
327
365
|
mode: EXECUTION_BUNDLE_FILE_MODE,
|
package/src/core/postpublish.mjs
CHANGED
|
@@ -481,6 +481,131 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
|
481
481
|
return postPublish;
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Version-aware read-only normalization of the frozen plan's postPublish
|
|
486
|
+
* field (multi-release-unit postPublish v3, design §4.2 归一表).
|
|
487
|
+
*
|
|
488
|
+
* This is the SINGLE normalization seam every reader (approve, distribute,
|
|
489
|
+
* postVerify, ship, verify, recovery, publish, reconcile, approval) must use
|
|
490
|
+
* to obtain the declaration array view. The accepted shape is derived from
|
|
491
|
+
* `plan.planVersion` ONLY — readers must never guess the shape from the
|
|
492
|
+
* field value (no shape sniffing), and no second normalizer or compatibility
|
|
493
|
+
* schema may exist. Pure and strictly read-only: never mutates the plan and
|
|
494
|
+
* never rewrites legacy plans, summaries, or evidence.
|
|
495
|
+
*
|
|
496
|
+
* | planVersion | input shape | result |
|
|
497
|
+
* |-------------|---------------|------------------|
|
|
498
|
+
* | 1/2 | absent | [] |
|
|
499
|
+
* | 1/2 | single object | [declaration] |
|
|
500
|
+
* | 1/2 | array | rejected |
|
|
501
|
+
* | 3 | array | the array itself |
|
|
502
|
+
* | 3 | object/absent | rejected |
|
|
503
|
+
*
|
|
504
|
+
* Values outside the table (null, primitives, unknown planVersion) fail
|
|
505
|
+
* closed with GATE_FAILED: a schema-valid plan can never produce them, and a
|
|
506
|
+
* reader must never paper over a shape it does not understand.
|
|
507
|
+
*
|
|
508
|
+
* @param {object} plan - Frozen plan (schema-valid by the caller's contract).
|
|
509
|
+
* @returns {object[]} The declaration array view (empty for legacy absent).
|
|
510
|
+
* @throws {ReleaseError} GATE_FAILED on any shape/version mismatch.
|
|
511
|
+
*/
|
|
512
|
+
export function normalizePostPublishView(plan) {
|
|
513
|
+
const version = plan?.planVersion;
|
|
514
|
+
const postPublish = plan?.postPublish;
|
|
515
|
+
if (version === 1 || version === 2) {
|
|
516
|
+
if (postPublish === undefined) return [];
|
|
517
|
+
if (Array.isArray(postPublish)) {
|
|
518
|
+
throw new ReleaseError(
|
|
519
|
+
GATE_FAILED,
|
|
520
|
+
`planVersion ${version} plans must not carry a postPublish array; only planVersion 3 accepts the array shape`,
|
|
521
|
+
{ planVersion: version, postPublishKind: 'array' },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (postPublish === null || typeof postPublish !== 'object') {
|
|
525
|
+
throw new ReleaseError(
|
|
526
|
+
GATE_FAILED,
|
|
527
|
+
`planVersion ${version} plans must carry a single postPublish object or none; found ${postPublish === null ? 'null' : typeof postPublish}`,
|
|
528
|
+
{ planVersion: version },
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return [postPublish];
|
|
532
|
+
}
|
|
533
|
+
if (version === 3) {
|
|
534
|
+
if (!Array.isArray(postPublish)) {
|
|
535
|
+
throw new ReleaseError(
|
|
536
|
+
GATE_FAILED,
|
|
537
|
+
`planVersion 3 plans must carry a postPublish array (possibly empty); found ${postPublish === undefined ? 'no postPublish field' : postPublish === null ? 'null' : 'a single object'}`,
|
|
538
|
+
{ planVersion: 3, postPublishKind: postPublish === undefined ? 'absent' : postPublish === null ? 'null' : 'object' },
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return postPublish;
|
|
542
|
+
}
|
|
543
|
+
throw new ReleaseError(
|
|
544
|
+
GATE_FAILED,
|
|
545
|
+
`cannot normalize postPublish shape: unknown planVersion ${String(version)}`,
|
|
546
|
+
{ planVersion: version },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Array-level domain validation: every EXPLICIT hooks[].id must be unique
|
|
552
|
+
* across the whole declaration array (multi-release-unit postPublish v3,
|
|
553
|
+
* design §9.2 rule 3; rework R-02). The (planDigest, hookId) approval
|
|
554
|
+
* contract binds each explicit hook id plan-wide, so a duplicate across
|
|
555
|
+
* units must fail before any approval is minted or consumed and before any
|
|
556
|
+
* side effect — JSON Schema cannot express cross-array business uniqueness,
|
|
557
|
+
* so this function is the SINGLE runtime authority for it.
|
|
558
|
+
*
|
|
559
|
+
* Every entry only normalizes its input to the declaration array view and
|
|
560
|
+
* calls this function: prepare, setup/adoption assessment, approve, the
|
|
561
|
+
* postpublish approval validator, distribute, and postVerify. Target and
|
|
562
|
+
* internal probe local ids may repeat across units (checkpoints are
|
|
563
|
+
* unit-namespaced); explicit hook ids may not.
|
|
564
|
+
*
|
|
565
|
+
* @param {object[]} declarations - The declaration array view
|
|
566
|
+
* (`normalizePostPublishView()` output, or the per-unit postPublish blocks
|
|
567
|
+
* collected by prepare/setup).
|
|
568
|
+
* @throws {ReleaseError} GATE_FAILED with details { hookId, unitIds } on the
|
|
569
|
+
* first cross-unit duplicate.
|
|
570
|
+
*/
|
|
571
|
+
export function validatePostPublishHookIdUniqueness(declarations) {
|
|
572
|
+
const explicitHookIdOwner = new Map();
|
|
573
|
+
for (const declaration of declarations ?? []) {
|
|
574
|
+
for (const hook of declaration?.hooks ?? []) {
|
|
575
|
+
const owner = explicitHookIdOwner.get(hook.id);
|
|
576
|
+
if (owner !== undefined && owner !== declaration.unitId) {
|
|
577
|
+
throw new ReleaseError(
|
|
578
|
+
GATE_FAILED,
|
|
579
|
+
`postPublish hook id "${hook.id}" is declared by units "${owner}" and "${declaration.unitId}"; explicit hooks[].id must be unique across the whole project (target and probe local ids may repeat across units)`,
|
|
580
|
+
{ hookId: hook.id, unitIds: [owner, declaration.unitId] },
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
explicitHookIdOwner.set(hook.id, declaration.unitId);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Deterministically derive the checkpoint action id for one postPublish
|
|
590
|
+
* local action (target probe/mirror or hook) inside one release unit
|
|
591
|
+
* (§4.3 checkpoint binding). planVersion 3 namespaces every action id under
|
|
592
|
+
* the owning declaration's unitId (`unitId/localId`); legacy plans keep the
|
|
593
|
+
* bare local id so pre-existing checkpoint records and approvals stay valid.
|
|
594
|
+
* This is the SINGLE derivation used by every consumer (distribute,
|
|
595
|
+
* postVerify, recovery, verify), so a checkpoint id can never drift between
|
|
596
|
+
* the run records, the recovery mapping, and the approval binding.
|
|
597
|
+
*
|
|
598
|
+
* @param {object} params
|
|
599
|
+
* @param {number} params.planVersion - Frozen plan's planVersion.
|
|
600
|
+
* @param {string} params.unitId - Owning declaration's unitId.
|
|
601
|
+
* @param {string} params.localId - Local action id (`probe-<target.id>`,
|
|
602
|
+
* `<target.id>`, or the hook id).
|
|
603
|
+
* @returns {string} The checkpoint action id.
|
|
604
|
+
*/
|
|
605
|
+
export function postPublishActionId({ planVersion, unitId, localId }) {
|
|
606
|
+
return planVersion === 3 ? `${unitId}/${localId}` : localId;
|
|
607
|
+
}
|
|
608
|
+
|
|
484
609
|
/**
|
|
485
610
|
* Order targets so every target comes after its dependsOn target.
|
|
486
611
|
*
|
|
@@ -595,24 +720,39 @@ export function effectiveHookRequiresApproval(hook) {
|
|
|
595
720
|
*
|
|
596
721
|
* @param {object} args
|
|
597
722
|
* @param {object} args.plan - Frozen plan (digest + units + postPublish).
|
|
723
|
+
* @param {object} args.postPublish - THE current declaration the caller is
|
|
724
|
+
* executing. REQUIRED (rework R-06): callers must pass the declaration
|
|
725
|
+
* explicitly — the unit identity is never derived from a global
|
|
726
|
+
* single-object read (contract §4.3). v1/v2 callers pass the single item
|
|
727
|
+
* obtained from normalizePostPublishView(); v3 callers pass their loop
|
|
728
|
+
* item. Omitting it fails closed with GATE_FAILED; a declaration ARRAY
|
|
729
|
+
* (or anything non-object) fails closed too.
|
|
598
730
|
* @param {string} args.runId - Current distribute run id.
|
|
599
731
|
* @param {object} args.sourceRun - Sealed source run (finishedAt = publishedAt).
|
|
600
732
|
* @param {string} args.payloadDir - Materialized payload directory.
|
|
601
733
|
* @param {'distribute'|'postVerify'} args.phase
|
|
602
734
|
* @param {object} [args.verifyEvidence] - Verify evidence (postVerify phase).
|
|
603
735
|
* @returns {object} The context projection.
|
|
736
|
+
* @throws {ReleaseError} GATE_FAILED when no single declaration is in scope.
|
|
604
737
|
*/
|
|
605
|
-
export function buildPostPublishContext({ plan, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
|
|
606
|
-
const
|
|
607
|
-
|
|
738
|
+
export function buildPostPublishContext({ plan, postPublish, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
|
|
739
|
+
const declaration = postPublish;
|
|
740
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
|
|
741
|
+
throw new ReleaseError(
|
|
742
|
+
GATE_FAILED,
|
|
743
|
+
`buildPostPublishContext requires the current postPublish declaration; found ${declaration === undefined ? 'no declaration' : Array.isArray(declaration) ? 'a declaration array' : typeof declaration}`,
|
|
744
|
+
{ planVersion: plan?.planVersion, unitId: declaration?.unitId ?? null },
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const unit = (plan.units ?? []).find((entry) => entry.id === declaration.unitId);
|
|
608
748
|
const frozenSnapshot = unit?.frozenSnapshot ?? {};
|
|
609
749
|
return {
|
|
610
750
|
planDigest: plan.digest,
|
|
611
751
|
runId,
|
|
612
|
-
unitId:
|
|
752
|
+
unitId: declaration.unitId,
|
|
613
753
|
version: unit?.targetVersion,
|
|
614
|
-
tag:
|
|
615
|
-
commit:
|
|
754
|
+
tag: declaration.tag,
|
|
755
|
+
commit: declaration.tagCommit,
|
|
616
756
|
...(frozenSnapshot.tree !== undefined ? { tree: frozenSnapshot.tree } : {}),
|
|
617
757
|
...(frozenSnapshot.manifestDigest !== undefined ? { manifestDigest: frozenSnapshot.manifestDigest } : {}),
|
|
618
758
|
publishedAt: sourceRun?.finishedAt,
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
* @module core/preset-executor
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { rm } from 'node:fs/promises';
|
|
26
|
+
|
|
25
27
|
import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
|
|
26
28
|
import { resolveProposalInboxTransport } from './presets.mjs';
|
|
27
29
|
import { executeNotifyHandoffHook } from './notify-handoff.mjs';
|
|
28
30
|
import {
|
|
31
|
+
buildProposalDocument,
|
|
29
32
|
executeProposalInboxGitPushHook,
|
|
30
33
|
executeProposalInboxLocalFileHook,
|
|
34
|
+
observeProposalInboxGitPush,
|
|
35
|
+
proposalFileName,
|
|
31
36
|
} from './proposal-inbox.mjs';
|
|
32
37
|
import { executeMarketplaceRegistryEntryHook } from './marketplace-registry-entry.mjs';
|
|
33
38
|
import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
|
|
@@ -173,3 +178,51 @@ export async function executePresetHook(params) {
|
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Narrow READ-ONLY preset preflight (rework R-01): deterministic conflicts a
|
|
184
|
+
* preset can already recognize from its existing observe-before-write
|
|
185
|
+
* observation, evaluated BEFORE the first external write of the phase. This
|
|
186
|
+
* is NOT a second dispatch table and NOT a lock — it only covers the presets
|
|
187
|
+
* whose observation implementation already exists (proposal-inbox git-push:
|
|
188
|
+
* the proposal path exists with different bytes -> the shared observation
|
|
189
|
+
* throws REMOTE_CONFLICT), and every preset hook is re-observed at execution
|
|
190
|
+
* time regardless.
|
|
191
|
+
*
|
|
192
|
+
* Returns `null` for presets/transports without a read-only deterministic
|
|
193
|
+
* conflict observation (proposal-inbox local-file, notify-handoff, ...) —
|
|
194
|
+
* their execution-phase observe-before-write stays the only gate.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} params
|
|
197
|
+
* @param {object} params.hook - Declared preset hook entry.
|
|
198
|
+
* @param {object} params.contextProjection - The deterministic §2.3 context
|
|
199
|
+
* projection of the current run (the same bytes the execution-phase
|
|
200
|
+
* delivery serializes; lineage-stable runId).
|
|
201
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
202
|
+
* @returns {Promise<{verdict: string, proposalPath: string}|null>}
|
|
203
|
+
* @throws {ReleaseError} REMOTE_CONFLICT/REMOTE_UNAVAILABLE from the shared
|
|
204
|
+
* observation implementation.
|
|
205
|
+
*/
|
|
206
|
+
export async function preflightPresetHook(params) {
|
|
207
|
+
const { hook, contextProjection, exec } = params ?? {};
|
|
208
|
+
if (!hook || typeof hook.preset !== 'string') return null;
|
|
209
|
+
if (hook.preset !== 'proposal-inbox') return null;
|
|
210
|
+
const target = hook.config?.target;
|
|
211
|
+
if (!target) return null; // targetOptional degradation: nothing remote to observe.
|
|
212
|
+
const transport = resolveProposalInboxTransport(hook.config);
|
|
213
|
+
if (transport !== 'git-push') return null; // local-file: no deterministic remote conflict observation.
|
|
214
|
+
|
|
215
|
+
// The same observation implementation the execution-phase delivery uses:
|
|
216
|
+
// build the byte-deterministic proposal and observe-before-write.
|
|
217
|
+
const document = buildProposalDocument(contextProjection);
|
|
218
|
+
const proposalPath = proposalFileName(contextProjection.unitId, contextProjection.version);
|
|
219
|
+
const { cloneDir, verdict } = await observeProposalInboxGitPush({
|
|
220
|
+
remoteUrl: target.remoteUrl,
|
|
221
|
+
branch: target.branch,
|
|
222
|
+
proposalPath,
|
|
223
|
+
proposalDocument: document,
|
|
224
|
+
...(exec !== undefined ? { exec } : {}),
|
|
225
|
+
});
|
|
226
|
+
await rm(cloneDir, { recursive: true, force: true }).catch(() => {});
|
|
227
|
+
return { verdict, proposalPath };
|
|
228
|
+
}
|
|
@@ -288,40 +288,42 @@ export async function crossCheckPushedCommit(exec, remoteUrl, branch, pushedComm
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
291
|
+
* Observe-before-write for the git-push transport (rework R-01): clone the
|
|
292
|
+
* downstream into a fresh disposable checkout and compare any existing
|
|
293
|
+
* proposal bytes with the serialized document. This is the SINGLE observation
|
|
294
|
+
* implementation shared by the execution-phase delivery (observe-before-write)
|
|
295
|
+
* and the full-declaration preflight (deterministic conflict detection BEFORE
|
|
296
|
+
* the first external write) — no second observe algorithm or dispatch table.
|
|
293
297
|
*
|
|
294
|
-
* - identical
|
|
295
|
-
* - different existing content -> REMOTE_CONFLICT (human decision)
|
|
296
|
-
*
|
|
298
|
+
* - existing identical content -> verdict 'IDENTICAL';
|
|
299
|
+
* - different existing content -> REMOTE_CONFLICT (human decision), thrown
|
|
300
|
+
* from here so preflight and delivery can never drift apart;
|
|
301
|
+
* - absent -> verdict 'ABSENT'.
|
|
297
302
|
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
303
|
+
* On success the CALLER owns `cloneDir` and must remove it (the delivery
|
|
304
|
+
* continues writing/committing/pushing from that checkout; the preflight
|
|
305
|
+
* discards it immediately). On any thrown failure the clone dir is already
|
|
306
|
+
* removed.
|
|
302
307
|
*
|
|
303
308
|
* @param {object} params
|
|
304
309
|
* @param {string} params.remoteUrl - Downstream repository URL.
|
|
305
310
|
* @param {string} params.branch - Downstream branch.
|
|
306
311
|
* @param {string} params.proposalPath - Repository-relative proposal path.
|
|
307
312
|
* @param {object} params.proposalDocument - The proposal document.
|
|
308
|
-
* @param {object} params.commitIdentity - Frozen { name, email }.
|
|
309
313
|
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
310
|
-
* @returns {Promise<{
|
|
314
|
+
* @returns {Promise<{cloneDir: string, previousHead: string|null, verdict: 'ABSENT'|'IDENTICAL'}>}
|
|
311
315
|
*/
|
|
312
|
-
export async function
|
|
316
|
+
export async function observeProposalInboxGitPush(params) {
|
|
313
317
|
const {
|
|
314
318
|
remoteUrl,
|
|
315
319
|
branch,
|
|
316
320
|
proposalPath,
|
|
317
321
|
proposalDocument,
|
|
318
|
-
commitIdentity,
|
|
319
322
|
exec: execOpt,
|
|
320
323
|
} = params ?? {};
|
|
321
324
|
assertSafeRemoteUrl(remoteUrl);
|
|
322
325
|
assertSafeBranch(branch);
|
|
323
326
|
assertSafeProposalPath(proposalPath);
|
|
324
|
-
assertCommitIdentity(commitIdentity);
|
|
325
327
|
if (!proposalDocument || typeof proposalDocument !== 'object' || Array.isArray(proposalDocument)) {
|
|
326
328
|
throw new ReleaseError(GATE_FAILED, 'proposal-inbox requires a proposal document');
|
|
327
329
|
}
|
|
@@ -365,7 +367,7 @@ export async function deliverProposalGitPush(params) {
|
|
|
365
367
|
await git(['checkout', '--quiet', '--orphan', branch]);
|
|
366
368
|
}
|
|
367
369
|
|
|
368
|
-
// Observe
|
|
370
|
+
// Observe: an existing proposal decides the outcome.
|
|
369
371
|
const targetPath = join(cloneDir, proposalPath);
|
|
370
372
|
let existing = null;
|
|
371
373
|
try {
|
|
@@ -373,20 +375,82 @@ export async function deliverProposalGitPush(params) {
|
|
|
373
375
|
} catch {
|
|
374
376
|
existing = null;
|
|
375
377
|
}
|
|
376
|
-
if (existing
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
throw new ReleaseError(
|
|
384
|
-
REMOTE_CONFLICT,
|
|
385
|
-
`proposal ${proposalPath} already exists in ${redactUrlCredentialsIfPresent(remoteUrl)} with different content; overwriting a downstream proposal requires a human decision`,
|
|
386
|
-
{ remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), proposalPath },
|
|
387
|
-
);
|
|
378
|
+
if (existing === null) {
|
|
379
|
+
return { cloneDir, previousHead, verdict: 'ABSENT' };
|
|
380
|
+
}
|
|
381
|
+
if (existing === serialized) {
|
|
382
|
+
return { cloneDir, previousHead, verdict: 'IDENTICAL' };
|
|
388
383
|
}
|
|
384
|
+
throw new ReleaseError(
|
|
385
|
+
REMOTE_CONFLICT,
|
|
386
|
+
`proposal ${proposalPath} already exists in ${redactUrlCredentialsIfPresent(remoteUrl)} with different content; overwriting a downstream proposal requires a human decision`,
|
|
387
|
+
{ remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), proposalPath },
|
|
388
|
+
);
|
|
389
|
+
} catch (cause) {
|
|
390
|
+
await rm(cloneDir, { recursive: true, force: true }).catch(() => {});
|
|
391
|
+
throw cause;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
389
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Deliver one proposal document through the git-push transport:
|
|
397
|
+
* observe -> write (absent only) -> commit (frozen identity) -> push.
|
|
398
|
+
*
|
|
399
|
+
* - identical existing content -> NO_CHANGE, nothing written;
|
|
400
|
+
* - different existing content -> REMOTE_CONFLICT (human decision);
|
|
401
|
+
* - NEVER --force.
|
|
402
|
+
*
|
|
403
|
+
* The observe-before-write step calls the SAME shared observation
|
|
404
|
+
* implementation the full-declaration preflight uses, so the execution phase
|
|
405
|
+
* always re-observes the remote (the preflight is never a lock).
|
|
406
|
+
*
|
|
407
|
+
* No preset-level dry-run (R4 review m-3): the command-level DRY_RUN skip
|
|
408
|
+
* contract skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints in
|
|
409
|
+
* distribute/postVerify) before this delivery is ever invoked, so a dryRun
|
|
410
|
+
* parameter here would be unreachable dead surface.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} params
|
|
413
|
+
* @param {string} params.remoteUrl - Downstream repository URL.
|
|
414
|
+
* @param {string} params.branch - Downstream branch.
|
|
415
|
+
* @param {string} params.proposalPath - Repository-relative proposal path.
|
|
416
|
+
* @param {object} params.proposalDocument - The proposal document.
|
|
417
|
+
* @param {object} params.commitIdentity - Frozen { name, email }.
|
|
418
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
419
|
+
* @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
|
|
420
|
+
*/
|
|
421
|
+
export async function deliverProposalGitPush(params) {
|
|
422
|
+
const {
|
|
423
|
+
remoteUrl,
|
|
424
|
+
branch,
|
|
425
|
+
proposalPath,
|
|
426
|
+
proposalDocument,
|
|
427
|
+
commitIdentity,
|
|
428
|
+
exec: execOpt,
|
|
429
|
+
} = params ?? {};
|
|
430
|
+
assertCommitIdentity(commitIdentity);
|
|
431
|
+
const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
|
|
432
|
+
const serialized = `${JSON.stringify(proposalDocument, null, 2)}\n`;
|
|
433
|
+
|
|
434
|
+
// Shared observe-before-write: identical -> NO_CHANGE; different -> the
|
|
435
|
+
// shared observation throws REMOTE_CONFLICT (human decision).
|
|
436
|
+
const { cloneDir, previousHead, verdict } = await observeProposalInboxGitPush({
|
|
437
|
+
remoteUrl,
|
|
438
|
+
branch,
|
|
439
|
+
proposalPath,
|
|
440
|
+
proposalDocument,
|
|
441
|
+
...(execOpt !== undefined ? { exec: execOpt } : {}),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
if (verdict === 'IDENTICAL') {
|
|
446
|
+
return {
|
|
447
|
+
status: 'NO_CHANGE',
|
|
448
|
+
observation: { mode: 'no-change', previousHead, branchTip: previousHead },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const git = (args, options = {}) => exec('git', args, { cwd: cloneDir, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
|
|
453
|
+
const targetPath = join(cloneDir, proposalPath);
|
|
390
454
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
391
455
|
await writeFile(targetPath, serialized);
|
|
392
456
|
await git(['add', '-A']);
|