release-skill 0.6.2 → 0.7.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.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +48 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +137 -12
  11. package/README.zh-CN.md +110 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/error-codes.json +1 -1
  15. package/adapters/claude/bin/kernel-protocol.json +1 -1
  16. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  17. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  18. package/adapters/claude/bin/registry.json +64 -1
  19. package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
  20. package/adapters/claude/bin/rules.json +1 -1
  21. package/adapters/claude/schemas/.render-manifest.json +10 -6
  22. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +200 -4
  24. package/adapters/claude/schemas/release-project.schema.json +86 -5
  25. package/adapters/claude/schemas/release-run.schema.json +11 -6
  26. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  27. package/adapters/codex/bin/error-codes.json +1 -1
  28. package/adapters/codex/bin/kernel-protocol.json +1 -1
  29. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  30. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  31. package/adapters/codex/bin/registry.json +64 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +10 -6
  35. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  36. package/adapters/codex/schemas/release-plan.schema.json +200 -4
  37. package/adapters/codex/schemas/release-project.schema.json +86 -5
  38. package/adapters/codex/schemas/release-run.schema.json +11 -6
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/error-codes.json +1 -1
  41. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  42. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  43. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  44. package/adapters/kimi/bin/registry.json +64 -1
  45. package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
  46. package/adapters/kimi/bin/rules.json +1 -1
  47. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  48. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/adapters/kimi/schemas/release-plan.schema.json +200 -4
  50. package/adapters/kimi/schemas/release-project.schema.json +86 -5
  51. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/error-codes.json +1 -1
  54. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  55. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  56. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  57. package/adapters/workbuddy/bin/registry.json +64 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
  59. package/adapters/workbuddy/bin/rules.json +1 -1
  60. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  61. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  62. package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
  63. package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
  64. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  65. package/bin/error-codes.json +1 -1
  66. package/bin/kernel-protocol.json +1 -1
  67. package/bin/license-texts/Apache-2.0.txt +201 -0
  68. package/bin/license-texts/MIT.txt +21 -0
  69. package/bin/registry.json +64 -1
  70. package/bin/release-skill-cli.mjs +187 -9
  71. package/bin/release-skill.bundle.mjs +50570 -42208
  72. package/bin/rules.json +1 -1
  73. package/package.json +6 -4
  74. package/platform-manifest.json +4 -4
  75. package/references/.render-manifest.json +5 -5
  76. package/references/01-state-machine.md +22 -2
  77. package/references/02-project-config.md +1 -1
  78. package/references/06-adapter-contract.md +6 -3
  79. package/schemas/.render-manifest.json +10 -6
  80. package/schemas/postpublish-approval-record.schema.json +47 -0
  81. package/schemas/release-plan.schema.json +200 -4
  82. package/schemas/release-project.schema.json +86 -5
  83. package/schemas/release-run.schema.json +11 -6
  84. package/src/adapters/distribute-git.mjs +17 -11
  85. package/src/commands/approve.mjs +163 -1
  86. package/src/commands/distribute.mjs +649 -89
  87. package/src/commands/postverify.mjs +808 -0
  88. package/src/commands/prepare.mjs +456 -45
  89. package/src/commands/setup.mjs +715 -0
  90. package/src/commands/ship.mjs +152 -5
  91. package/src/commands/verify.mjs +92 -15
  92. package/src/core/approval.mjs +93 -68
  93. package/src/core/bounded-output.mjs +46 -0
  94. package/src/core/derived-artifact-gates.mjs +258 -0
  95. package/src/core/docs-refresh-preset.mjs +171 -0
  96. package/src/core/errors.mjs +4 -0
  97. package/src/core/evidence.mjs +8 -1
  98. package/src/core/git-url-policy.mjs +214 -0
  99. package/src/core/hooks.mjs +28 -0
  100. package/src/core/marketplace-registry-entry.mjs +175 -0
  101. package/src/core/notify-handoff.mjs +76 -0
  102. package/src/core/postpublish-approval.mjs +241 -0
  103. package/src/core/postpublish-bundle.mjs +342 -0
  104. package/src/core/postpublish-projection.mjs +210 -0
  105. package/src/core/postpublish.mjs +449 -25
  106. package/src/core/preset-executor.mjs +175 -0
  107. package/src/core/preset-gitwrite.mjs +484 -0
  108. package/src/core/presets.mjs +754 -0
  109. package/src/core/proposal-inbox.mjs +646 -0
  110. package/src/core/redact.mjs +16 -3
  111. package/src/core/run.mjs +91 -6
  112. package/src/producers/build-adapters.mjs +28 -14
@@ -23,7 +23,13 @@
23
23
  * @module core/postpublish
24
24
  */
25
25
 
26
- import { ReleaseError, GATE_FAILED } from './errors.mjs';
26
+ import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
27
+ import {
28
+ postPublishPresetNames,
29
+ validatePresetHook,
30
+ resolvePresetRequiresApproval,
31
+ } from './presets.mjs';
32
+ import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
27
33
 
28
34
  /** Secret-ish environment variable denylist (R3 credential hygiene). */
29
35
  export const ENV_ALLOWLIST_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
@@ -34,9 +40,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
34
40
  /** Safe target/step id and step name pattern. */
35
41
  const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
36
42
 
37
- /** Remote URL pattern: http(s) or file (test transport) and .git-suffixed. */
38
- const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
39
-
40
43
  /** Branch pattern (leading alphanumeric blocks option-like names). */
41
44
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
42
45
 
@@ -116,10 +119,13 @@ function validateTarget(target, index) {
116
119
  if (target.kind !== 'payload-mirror' && target.kind !== 'marketplace-index') {
117
120
  fail(`${where}.kind must be "payload-mirror" or "marketplace-index"`, { kind: target.kind });
118
121
  }
119
- if (typeof target.remoteUrl !== 'string' || !REMOTE_URL_RE.test(target.remoteUrl)) {
120
- fail(`${where}.remoteUrl must be an http(s)/file URL ending in .git`, { remoteUrl: target.remoteUrl });
122
+ const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
123
+ if (!remoteUrlVerdict.ok) {
124
+ fail(
125
+ `${where}.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
126
+ { reason: remoteUrlVerdict.reason },
127
+ );
121
128
  }
122
- assertNoControlChars(`${where}.remoteUrl`, target.remoteUrl);
123
129
  if (target.visibility !== 'internal' && target.visibility !== 'public') {
124
130
  fail(`${where}.visibility must be "internal" or "public"`, { visibility: target.visibility });
125
131
  }
@@ -178,6 +184,168 @@ function validateTarget(target, index) {
178
184
  }
179
185
  }
180
186
 
187
+ function failHook(message, details = {}) {
188
+ throw new ReleaseError(POSTPUBLISH_HOOK_INVALID, `postPublish hook invalid: ${message}`, details);
189
+ }
190
+
191
+ /**
192
+ * Re-check the command-execution safety fields shared by materialize/steps
193
+ * and postPublish command hooks. Throws POSTPUBLISH_HOOK_INVALID (hooks) or
194
+ * GATE_FAILED (materialize/steps, via `fail`) depending on `failFn`.
195
+ */
196
+ function validateCommandFields(where, hook, failFn) {
197
+ if (!Array.isArray(hook.command) || hook.command.length === 0) {
198
+ failFn(`${where}.command must be a non-empty array (shell strings are never accepted)`);
199
+ }
200
+ for (const element of hook.command) {
201
+ if (typeof element !== 'string' || element.length === 0) {
202
+ failFn(`${where}.command must contain only non-empty strings`);
203
+ }
204
+ if (/[\x00-\x1f\x7f]/.test(element)) {
205
+ failFn(`${where}.command contains control characters`, { element });
206
+ }
207
+ if (element.startsWith('-') && element === hook.command[0]) {
208
+ failFn(`${where}.command executable must not start with "-"`, { executable: element });
209
+ }
210
+ }
211
+ if (hook.cwd !== undefined) {
212
+ if (typeof hook.cwd !== 'string' || hook.cwd.length === 0) {
213
+ failFn(`${where}.cwd must be a non-empty string when provided`);
214
+ }
215
+ if (hook.cwd.startsWith('/') || hook.cwd.startsWith('./') || hook.cwd.includes('..')) {
216
+ failFn(`${where}.cwd must be a relative path inside the execution root`, { cwd: hook.cwd });
217
+ }
218
+ }
219
+ if (hook.timeoutMs !== undefined) {
220
+ if (!Number.isInteger(hook.timeoutMs) || hook.timeoutMs < 1000 || hook.timeoutMs > 7200000) {
221
+ failFn(`${where}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: hook.timeoutMs });
222
+ }
223
+ }
224
+ if (hook.envAllowlist !== undefined) {
225
+ if (!Array.isArray(hook.envAllowlist)) {
226
+ failFn(`${where}.envAllowlist must be an array`);
227
+ }
228
+ const seen = new Set();
229
+ for (const key of hook.envAllowlist) {
230
+ if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key)) {
231
+ failFn(`${where}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
232
+ }
233
+ if (ENV_ALLOWLIST_DENYLIST.test(key)) {
234
+ failFn(
235
+ `${where}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); distribute never reads or forwards credentials`,
236
+ { key },
237
+ );
238
+ }
239
+ if (seen.has(key)) {
240
+ failFn(`${where}.envAllowlist contains duplicate key "${key}"`);
241
+ }
242
+ seen.add(key);
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Validate one postPublish hooks[] entry (v0.6.3 R1). Every violation throws
249
+ * POSTPUBLISH_HOOK_INVALID: the hooks layer is fail-closed and never repairs.
250
+ *
251
+ * Rules:
252
+ * - id mandatory, /^[a-z0-9][a-z0-9._-]*$/;
253
+ * - preset XOR command: exactly one must be declared;
254
+ * - preset references must exist in `knownPresets` (R2: defaults to the
255
+ * built-in preset registry; an explicit empty list keeps the R1
256
+ * fail-closed stance);
257
+ * - preset configs are validated by the registry (dual addressing,
258
+ * marketplace/staticFiles shapes, secret scan) — POSTPUBLISH_HOOK_INVALID;
259
+ * - requiresApproval may tighten but never relax below the preset default;
260
+ * - command entries: executable+argument array safety (no shell strings);
261
+ * - phase: "distribute" (default) or "postVerify";
262
+ * - requiresApproval/blocksVerified booleans; blocksVerified: false is a
263
+ * preset-only permission — custom command hooks can never weaken the
264
+ * VERIFIED gate;
265
+ * - preset hooks must not declare command-hook execution fields
266
+ * (cwd/timeoutMs/envAllowlist); command hooks must not declare config.
267
+ *
268
+ * @param {object} hook
269
+ * @param {number} index
270
+ * @param {object} options - { knownPresets: string[] }
271
+ */
272
+ function validatePostPublishHookEntry(hook, index, options) {
273
+ const where = `hooks[${index}]`;
274
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
275
+ failHook(`${where} must be a non-null object`);
276
+ }
277
+ if (typeof hook.id !== 'string' || !SAFE_ID_RE.test(hook.id)) {
278
+ failHook(`${where}.id must match /^[a-z0-9][a-z0-9._-]*$/`, { id: hook.id });
279
+ }
280
+
281
+ const hasPreset = hook.preset !== undefined;
282
+ const hasCommand = hook.command !== undefined;
283
+ if (hasPreset && hasCommand) {
284
+ failHook(`${where}: preset and command are mutually exclusive — declare exactly one`, { id: hook.id });
285
+ }
286
+ if (!hasPreset && !hasCommand) {
287
+ failHook(`${where}: declare exactly one of preset or command`, { id: hook.id });
288
+ }
289
+
290
+ if (hook.phase !== undefined && hook.phase !== 'distribute' && hook.phase !== 'postVerify') {
291
+ failHook(`${where}.phase must be "distribute" or "postVerify"`, { phase: hook.phase });
292
+ }
293
+ if (hook.requiresApproval !== undefined && typeof hook.requiresApproval !== 'boolean') {
294
+ failHook(`${where}.requiresApproval must be a boolean`, { requiresApproval: hook.requiresApproval });
295
+ }
296
+ if (hook.blocksVerified !== undefined && typeof hook.blocksVerified !== 'boolean') {
297
+ failHook(`${where}.blocksVerified must be a boolean`, { blocksVerified: hook.blocksVerified });
298
+ }
299
+
300
+ if (hasPreset) {
301
+ if (typeof hook.preset !== 'string' || !SAFE_ID_RE.test(hook.preset)) {
302
+ failHook(`${where}.preset must match /^[a-z0-9][a-z0-9._-]*$/`, { preset: hook.preset });
303
+ }
304
+ // R2: the built-in preset registry is the default authority; callers may
305
+ // still override with an explicit list (an empty list keeps the R1
306
+ // fail-closed stance where every preset reference is rejected).
307
+ const knownPresets = options.knownPresets ?? postPublishPresetNames();
308
+ if (!knownPresets.includes(hook.preset)) {
309
+ failHook(
310
+ `${where}: unknown preset "${hook.preset}" — the preset registry does not contain it (fail-closed)`,
311
+ { preset: hook.preset, knownPresets },
312
+ );
313
+ }
314
+ if (hook.config !== undefined && (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null)) {
315
+ failHook(`${where}.config must be a plain object when provided`, { id: hook.id });
316
+ }
317
+ for (const field of ['cwd', 'timeoutMs', 'envAllowlist']) {
318
+ if (hook[field] !== undefined) {
319
+ failHook(`${where}: preset hooks must not declare command-hook execution field "${field}"`, { field });
320
+ }
321
+ }
322
+ // Registry-driven config validation (dual addressing, marketplace block,
323
+ // staticFiles, secret scan) — fail-closed per preset (R2, §2.5/§2.6).
324
+ validatePresetHook(hook, where);
325
+ // requiresApproval grading (§2.6): projects may tighten (explicit true)
326
+ // but never relax below the preset-declared default.
327
+ const presetDefault = resolvePresetRequiresApproval(hook.preset, hook.config);
328
+ if (hook.requiresApproval === false && presetDefault === true) {
329
+ failHook(
330
+ `${where}: requiresApproval cannot be relaxed below the preset default (preset "${hook.preset}" defaults to true; declare true to tighten, never false to relax)`,
331
+ { preset: hook.preset, presetDefault },
332
+ );
333
+ }
334
+ } else {
335
+ // Custom command hook.
336
+ validateCommandFields(where, hook, failHook);
337
+ if (hook.blocksVerified === false) {
338
+ failHook(
339
+ `${where}: custom command hooks must not declare blocksVerified: false — only presets may weaken the VERIFIED gate`,
340
+ { id: hook.id },
341
+ );
342
+ }
343
+ if (hook.config !== undefined) {
344
+ failHook(`${where}: config is a preset-only field`, { id: hook.id });
345
+ }
346
+ }
347
+ }
348
+
181
349
  /**
182
350
  * Validate a per-unit postPublish declaration.
183
351
  *
@@ -185,8 +353,13 @@ function validateTarget(target, index) {
185
353
  * the caller's config layer; this function is the runtime re-check).
186
354
  * @param {object} [options]
187
355
  * @param {string} [options.unitId] - Unit id for error context.
356
+ * @param {string[]} [options.knownPresets] - Preset names accepted as known.
357
+ * Defaults to the built-in R2 preset registry (core/presets.mjs); an
358
+ * explicit empty list restores the R1 fail-closed stance where every
359
+ * preset reference is rejected.
188
360
  * @returns {object} The validated declaration (unmodified).
189
- * @throws {ReleaseError} GATE_FAILED on any violation (fail-closed).
361
+ * @throws {ReleaseError} GATE_FAILED on declaration violations;
362
+ * POSTPUBLISH_HOOK_INVALID on hooks[] violations (fail-closed).
190
363
  */
191
364
  export function validatePostPublishDeclaration(postPublish, options = {}) {
192
365
  const unitLabel = options.unitId ? `unit "${options.unitId}" ` : '';
@@ -194,27 +367,38 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
194
367
  fail(`${unitLabel}postPublish must be a non-null object`);
195
368
  }
196
369
 
197
- validateHookCommand(`${unitLabel}materialize`, postPublish.materialize);
198
- if (typeof postPublish.materialize.outputMarker !== 'string'
199
- || postPublish.materialize.outputMarker.length === 0) {
200
- fail(`${unitLabel}materialize.outputMarker must be a non-empty string`);
201
- }
202
- assertNoControlChars(`${unitLabel}materialize.outputMarker`, postPublish.materialize.outputMarker);
203
- if (postPublish.materialize.requireReport !== undefined) {
204
- const { parse, equals } = postPublish.materialize.requireReport ?? {};
205
- if (parse !== 'stdout-first-json') {
206
- fail(`${unitLabel}materialize.requireReport.parse must be "stdout-first-json"`);
370
+ // materialize is optional since F-06/T6: a declaration without a consumer
371
+ // materialize hook stages the payload through the frozen publicFiles
372
+ // managed projection (Foundation Engineering Kit). When present, the hook
373
+ // fields stay fail-closed exactly as before.
374
+ if (postPublish.materialize !== undefined) {
375
+ validateHookCommand(`${unitLabel}materialize`, postPublish.materialize);
376
+ if (typeof postPublish.materialize.outputMarker !== 'string'
377
+ || postPublish.materialize.outputMarker.length === 0) {
378
+ fail(`${unitLabel}materialize.outputMarker must be a non-empty string`);
207
379
  }
208
- if (equals !== undefined && (typeof equals !== 'object' || Array.isArray(equals) || equals === null)) {
209
- fail(`${unitLabel}materialize.requireReport.equals must be a plain object`);
380
+ assertNoControlChars(`${unitLabel}materialize.outputMarker`, postPublish.materialize.outputMarker);
381
+ if (postPublish.materialize.requireReport !== undefined) {
382
+ const { parse, equals } = postPublish.materialize.requireReport ?? {};
383
+ if (parse !== 'stdout-first-json') {
384
+ fail(`${unitLabel}materialize.requireReport.parse must be "stdout-first-json"`);
385
+ }
386
+ if (equals !== undefined && (typeof equals !== 'object' || Array.isArray(equals) || equals === null)) {
387
+ fail(`${unitLabel}materialize.requireReport.equals must be a plain object`);
388
+ }
210
389
  }
211
390
  }
212
391
 
213
- if (!Array.isArray(postPublish.targets) || postPublish.targets.length === 0) {
214
- fail(`${unitLabel}targets must be a non-empty array`);
392
+ // targets: optional since v0.6.3 R1 (hooks-only declarations are legal).
393
+ // An explicitly empty array still fails closed (semantic lock: a present
394
+ // targets array must declare at least one target).
395
+ const targets = postPublish.targets ?? [];
396
+ if (postPublish.targets !== undefined
397
+ && (!Array.isArray(postPublish.targets) || postPublish.targets.length === 0)) {
398
+ fail(`${unitLabel}targets must be a non-empty array when present; omit it entirely for a hooks-only declaration`);
215
399
  }
216
400
  const ids = new Set();
217
- postPublish.targets.forEach((target, index) => {
401
+ targets.forEach((target, index) => {
218
402
  validateTarget(target, index);
219
403
  if (ids.has(target.id)) {
220
404
  fail(`${unitLabel}duplicate target id "${target.id}"`);
@@ -223,8 +407,8 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
223
407
  });
224
408
 
225
409
  // dependsOn references must exist and point at payload-mirror targets.
226
- const byId = new Map(postPublish.targets.map((target) => [target.id, target]));
227
- for (const target of postPublish.targets) {
410
+ const byId = new Map(targets.map((target) => [target.id, target]));
411
+ for (const target of targets) {
228
412
  if (target.dependsOn === undefined) continue;
229
413
  const dependency = byId.get(target.dependsOn);
230
414
  if (!dependency) {
@@ -235,6 +419,27 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
235
419
  }
236
420
  }
237
421
 
422
+ // hooks (v0.6.3 R1): per-entry fail-closed validation + unique ids across
423
+ // the normalized target/hook table.
424
+ if (postPublish.hooks !== undefined) {
425
+ if (!Array.isArray(postPublish.hooks)) {
426
+ failHook(`${unitLabel}hooks must be an array`);
427
+ }
428
+ postPublish.hooks.forEach((hook, index) => {
429
+ validatePostPublishHookEntry(hook, index, options);
430
+ });
431
+ const hookIds = new Set();
432
+ for (const hook of postPublish.hooks) {
433
+ if (hookIds.has(hook.id)) {
434
+ failHook(`${unitLabel}duplicate hook id "${hook.id}"`);
435
+ }
436
+ hookIds.add(hook.id);
437
+ if (ids.has(hook.id)) {
438
+ failHook(`${unitLabel}hook id "${hook.id}" conflicts with target id "${hook.id}"; normalized table ids must be unique`);
439
+ }
440
+ }
441
+ }
442
+
238
443
  // commitIdentity is mandatory whenever targets exist (always, here).
239
444
  const identity = postPublish.commitIdentity;
240
445
  if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
@@ -313,3 +518,222 @@ export function orderTargetsByDependency(targets) {
313
518
  }
314
519
  return ordered;
315
520
  }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Hook normalization + context contract (v0.6.3 R1, design §2.3)
524
+ // ---------------------------------------------------------------------------
525
+
526
+ /**
527
+ * Environment variable carrying the read-only postPublish hook context.
528
+ * The JSON projection is injected into the hook process environment AFTER
529
+ * envAllowlist filtering (core/hooks.mjs injectEnv), so declarations can
530
+ * neither opt out of it nor smuggle it through their allowlist.
531
+ */
532
+ export const POSTPUBLISH_CONTEXT_ENV = 'RELEASE_SKILL_POSTPUBLISH_CONTEXT';
533
+
534
+ /**
535
+ * Normalize one declared postPublish hook entry: apply the governance
536
+ * defaults (phase distribute, blocksVerified true, requiresApproval false)
537
+ * and tag the entry kind. Pure function; the declaration is not mutated.
538
+ * This normalized shape is the digest-bound display surface shown at
539
+ * checkpoint approval time (review N-6).
540
+ *
541
+ * @param {object} hook - Declared hook entry (validate first for safety).
542
+ * @returns {object} Normalized entry: { id, kind, phase, blocksVerified,
543
+ * requiresApproval, + kind-specific fields (command/cwd/timeoutMs/
544
+ * envAllowlist or preset/config) }.
545
+ */
546
+ export function normalizePostPublishHook(hook) {
547
+ const kind = hook.preset !== undefined ? 'preset' : 'command';
548
+ // requiresApproval grading (§2.6): command hooks default false; preset
549
+ // hooks inherit the preset-declared default (public-write presets true,
550
+ // notify-handoff / proposal-inbox local-file false; validation forbids
551
+ // relaxing below the default, so this resolution can only ever confirm or
552
+ // tighten).
553
+ const defaultRequiresApproval = kind === 'preset'
554
+ ? resolvePresetRequiresApproval(hook.preset, hook.config)
555
+ : false;
556
+ const normalized = {
557
+ id: hook.id,
558
+ kind,
559
+ ...(kind === 'preset' ? { preset: hook.preset } : { command: [...hook.command] }),
560
+ phase: hook.phase ?? 'distribute',
561
+ ...(hook.config !== undefined ? { config: hook.config } : {}),
562
+ ...(kind === 'command' && hook.cwd !== undefined ? { cwd: hook.cwd } : {}),
563
+ ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
564
+ ...(hook.envAllowlist !== undefined ? { envAllowlist: [...hook.envAllowlist] } : {}),
565
+ requiresApproval: hook.requiresApproval ?? defaultRequiresApproval,
566
+ blocksVerified: hook.blocksVerified ?? true,
567
+ };
568
+ return normalized;
569
+ }
570
+
571
+ /**
572
+ * Effective requiresApproval (§2.6 grading) for one declared hook: command
573
+ * hooks carry their declared value; preset hooks inherit the preset-declared
574
+ * default (public-write presets true; proposal-inbox graded by transport;
575
+ * notify-handoff / local-file false) unless explicitly tightened. Single
576
+ * authority shared by distribute, postverify, and the ship re-entry gate.
577
+ *
578
+ * @param {object} hook - Declared hook entry.
579
+ * @returns {boolean}
580
+ */
581
+ export function effectiveHookRequiresApproval(hook) {
582
+ if (hook.requiresApproval !== undefined) return hook.requiresApproval === true;
583
+ if (hook.preset !== undefined) return resolvePresetRequiresApproval(hook.preset, hook.config) === true;
584
+ return false;
585
+ }
586
+
587
+ /**
588
+ * Build the read-only context projection injected into postPublish hooks
589
+ * (design §2.3). Every field comes from the frozen plan or the sealed source
590
+ * run — never from the live workspace.
591
+ *
592
+ * Phase distinction: `verifyEvidence` is carried ONLY for postVerify-phase
593
+ * hooks; for the distribute phase the key is ABSENT (not null), so hooks can
594
+ * distinguish the phases without trusting a mutable value.
595
+ *
596
+ * @param {object} args
597
+ * @param {object} args.plan - Frozen plan (digest + units + postPublish).
598
+ * @param {string} args.runId - Current distribute run id.
599
+ * @param {object} args.sourceRun - Sealed source run (finishedAt = publishedAt).
600
+ * @param {string} args.payloadDir - Materialized payload directory.
601
+ * @param {'distribute'|'postVerify'} args.phase
602
+ * @param {object} [args.verifyEvidence] - Verify evidence (postVerify phase).
603
+ * @returns {object} The context projection.
604
+ */
605
+ export function buildPostPublishContext({ plan, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
606
+ const postPublish = plan.postPublish ?? {};
607
+ const unit = (plan.units ?? []).find((entry) => entry.id === postPublish.unitId);
608
+ const frozenSnapshot = unit?.frozenSnapshot ?? {};
609
+ return {
610
+ planDigest: plan.digest,
611
+ runId,
612
+ unitId: postPublish.unitId,
613
+ version: unit?.targetVersion,
614
+ tag: postPublish.tag,
615
+ commit: postPublish.tagCommit,
616
+ ...(frozenSnapshot.tree !== undefined ? { tree: frozenSnapshot.tree } : {}),
617
+ ...(frozenSnapshot.manifestDigest !== undefined ? { manifestDigest: frozenSnapshot.manifestDigest } : {}),
618
+ publishedAt: sourceRun?.finishedAt,
619
+ payloadDir,
620
+ ...(phase === 'postVerify' && verifyEvidence !== undefined ? { verifyEvidence } : {}),
621
+ };
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Targets normalization (v0.6.3 R2, design §2.2)
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /** Legacy target kind -> absorbing preset (design §2.5). */
629
+ const TARGET_KIND_PRESET = {
630
+ 'payload-mirror': 'git-mirror',
631
+ 'marketplace-index': 'marketplace-index-render',
632
+ };
633
+
634
+ /**
635
+ * Normalize one legacy targets[] entry into a preset hook entry. Field-level
636
+ * mapping (review N-B2): remoteUrl/branch -> config.target; visibility ->
637
+ * config.visibility (public-write/internal-write semantics preserved);
638
+ * staticFiles -> config.staticFiles; marketplace -> config.marketplace;
639
+ * dependsOn -> hook-level dependency (runtime validates existence + acyclicity,
640
+ * semantics unchanged).
641
+ *
642
+ * @param {object} target - Validated postPublish target entry.
643
+ * @returns {object} Normalized preset hook entry.
644
+ */
645
+ function normalizeTargetToPresetHook(target) {
646
+ const preset = TARGET_KIND_PRESET[target.kind];
647
+ return {
648
+ id: target.id,
649
+ kind: 'preset',
650
+ preset,
651
+ origin: 'target',
652
+ originKind: target.kind,
653
+ phase: 'distribute',
654
+ config: {
655
+ target: { remoteUrl: target.remoteUrl, branch: target.branch },
656
+ visibility: target.visibility,
657
+ ...(target.staticFiles !== undefined
658
+ ? { staticFiles: target.staticFiles.map((file) => ({ from: file.from, to: file.to })) }
659
+ : {}),
660
+ ...(target.marketplace !== undefined
661
+ ? { marketplace: structuredClone(target.marketplace) }
662
+ : {}),
663
+ },
664
+ ...(target.dependsOn !== undefined ? { dependsOn: target.dependsOn } : {}),
665
+ requiresApproval: resolvePresetRequiresApproval(preset, {}),
666
+ blocksVerified: true,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Normalize a validated postPublish declaration into the unified hook table
672
+ * (design §2.2). Pure and deterministic: the table is a projection of the
673
+ * digest-bound declaration, so any targets/hooks list change changes the plan
674
+ * digest and invalidates existing approvals.
675
+ *
676
+ * Shape:
677
+ * - `preGates`: section-level gates injected ahead of all git-write hooks —
678
+ * assertMainVersionAhead:true becomes `assert-main-version-ahead` (current
679
+ * distribute semantics preserved);
680
+ * - `defaults`: section-level defaults injected into the relevant presets —
681
+ * materialize / steps / commitIdentity;
682
+ * - `hooks`: target-derived preset hooks first (declaration order), then the
683
+ * declared hooks[] entries normalized via normalizePostPublishHook.
684
+ *
685
+ * Validate the declaration BEFORE calling this (id conflicts, dependency
686
+ * references, and preset semantics are fail-closed there, not here).
687
+ *
688
+ * @param {object} postPublish - Validated postPublish declaration.
689
+ * @returns {{ preGates: object[], defaults: object, hooks: object[] }}
690
+ */
691
+ export function normalizePostPublishDeclaration(postPublish) {
692
+ const preGates = postPublish.assertMainVersionAhead === true
693
+ ? [{ gate: 'assert-main-version-ahead', before: 'git-write-hooks' }]
694
+ : [];
695
+ const defaults = {
696
+ materialize: structuredClone(postPublish.materialize),
697
+ steps: structuredClone(postPublish.steps ?? []),
698
+ commitIdentity: structuredClone(postPublish.commitIdentity),
699
+ };
700
+ const targetHooks = (postPublish.targets ?? []).map((target) => normalizeTargetToPresetHook(target));
701
+ const declaredHooks = (postPublish.hooks ?? []).map((hook) => normalizePostPublishHook(hook));
702
+ return { preGates, defaults, hooks: [...targetHooks, ...declaredHooks] };
703
+ }
704
+
705
+ /**
706
+ * Order the normalized hook table so every hook runs after its dependsOn
707
+ * hook, preserving declaration order among ready entries (dependency
708
+ * topology + declaration order). Dangling references and cycles fail closed
709
+ * with GATE_FAILED — references are also validated at declaration time; this
710
+ * guard keeps ordering independently safe.
711
+ *
712
+ * @param {object[]} hooks - Normalized hook table.
713
+ * @returns {object[]} Hooks in execution order.
714
+ */
715
+ export function orderNormalizedHooks(hooks) {
716
+ const byId = new Map(hooks.map((hook) => [hook.id, hook]));
717
+ const ordered = [];
718
+ const placed = new Set();
719
+ while (ordered.length < hooks.length) {
720
+ let progress = false;
721
+ for (const hook of hooks) {
722
+ if (placed.has(hook.id)) continue;
723
+ if (hook.dependsOn !== undefined) {
724
+ if (!byId.has(hook.dependsOn)) {
725
+ fail(`hook "${hook.id}" dependsOn unknown hook "${hook.dependsOn}"`);
726
+ }
727
+ if (!placed.has(hook.dependsOn)) continue;
728
+ }
729
+ ordered.push(hook);
730
+ placed.add(hook.id);
731
+ progress = true;
732
+ }
733
+ if (!progress) {
734
+ const pending = hooks.filter((hook) => !placed.has(hook.id)).map((hook) => hook.id);
735
+ fail(`postPublish hook dependency cycle detected among: ${pending.join(', ')}`);
736
+ }
737
+ }
738
+ return ordered;
739
+ }