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.
- 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 +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
package/src/core/postpublish.mjs
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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(
|
|
227
|
-
for (const target of
|
|
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
|
+
}
|