release-skill 0.6.2 → 0.6.3
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 +23 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +126 -9
- package/README.zh-CN.md +108 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +6408 -1654
- 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 +65 -1
- package/adapters/claude/schemas/release-project.schema.json +73 -1
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
- 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 +65 -1
- package/adapters/codex/schemas/release-project.schema.json +73 -1
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
- 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 +65 -1
- package/adapters/kimi/schemas/release-project.schema.json +73 -1
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
- 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 +65 -1
- package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/release-skill-cli.mjs +181 -3
- package/bin/release-skill.bundle.mjs +6408 -1654
- package/package.json +2 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +65 -1
- package/schemas/release-project.schema.json +73 -1
- package/schemas/release-run.schema.json +11 -6
- package/src/commands/approve.mjs +167 -1
- package/src/commands/distribute.mjs +411 -33
- package/src/commands/postverify.mjs +734 -0
- package/src/commands/prepare.mjs +280 -42
- 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 +167 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +174 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +110 -0
- package/src/core/postpublish.mjs +424 -7
- package/src/core/preset-executor.mjs +156 -0
- package/src/core/preset-gitwrite.mjs +463 -0
- package/src/core/presets.mjs +706 -0
- package/src/core/proposal-inbox.mjs +630 -0
- package/src/core/run.mjs +91 -6
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-handoff preset: the zero-write floor (v0.6.3 R4, design §2.5).
|
|
3
|
+
*
|
|
4
|
+
* Every downstream scenario degrades to at least this behavior: NO writes of
|
|
5
|
+
* any kind — the §2.3 frozen context is rendered into a DETERMINISTIC
|
|
6
|
+
* downstream sync checklist (version/tag/sha/tree/evidence path/suggested
|
|
7
|
+
* actions), which the command layer writes into the run evidence and echoes.
|
|
8
|
+
* Zero configuration, usable by any project, requiresApproval defaults false.
|
|
9
|
+
*
|
|
10
|
+
* The renderer is pure: identical inputs produce byte-identical checklists
|
|
11
|
+
* (snapshot-locked by test/postpublish-notify-handoff.test.mjs). payloadDir
|
|
12
|
+
* and any other local-only artifact never enter the checklist.
|
|
13
|
+
*
|
|
14
|
+
* @module core/notify-handoff
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Context fields rendered into the checklist, in deterministic order. */
|
|
18
|
+
const CHECKLIST_FACT_FIELDS = ['unitId', 'version', 'tag', 'commit', 'tree', 'manifestDigest', 'planDigest', 'runId', 'publishedAt'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render the deterministic downstream sync checklist for the notify-handoff
|
|
22
|
+
* preset. Pure function — no I/O, no writes.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} contextProjection - The §2.3 context projection.
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {string} [options.evidencePath] - This run's evidence path (rendered
|
|
27
|
+
* as the `evidence:` line when provided).
|
|
28
|
+
* @returns {string[]} Checklist lines (deterministic order and wording).
|
|
29
|
+
*/
|
|
30
|
+
export function renderNotifyHandoffChecklist(contextProjection, options = {}) {
|
|
31
|
+
const context = contextProjection ?? {};
|
|
32
|
+
const lines = [
|
|
33
|
+
'notify-handoff downstream sync checklist (zero-write floor; no automated write was performed)',
|
|
34
|
+
];
|
|
35
|
+
for (const field of CHECKLIST_FACT_FIELDS) {
|
|
36
|
+
const value = context[field];
|
|
37
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
38
|
+
lines.push(`- ${field}: ${value}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const verifyEvidence = context.verifyEvidence;
|
|
42
|
+
if (verifyEvidence && typeof verifyEvidence === 'object') {
|
|
43
|
+
lines.push(`- verifyEvidence: runId=${verifyEvidence.runId ?? ''} status=${verifyEvidence.status ?? ''} finishedAt=${verifyEvidence.finishedAt ?? ''}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof options.evidencePath === 'string' && options.evidencePath.length > 0) {
|
|
46
|
+
lines.push(`- evidence: ${options.evidencePath}`);
|
|
47
|
+
}
|
|
48
|
+
lines.push('suggested actions:');
|
|
49
|
+
lines.push('- manually sync the frozen release facts above into every downstream consumer (marketplace entries, docs sites, hub registries)');
|
|
50
|
+
lines.push('- downstream updates require human action or the downstream governance workflow; this hook wrote nothing');
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Execute one notify-handoff preset hook: render the checklist. The command
|
|
56
|
+
* layer evidences/echoes it; this module performs zero writes.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} params
|
|
59
|
+
* @param {object} params.contextProjection - The §2.3 context projection.
|
|
60
|
+
* @param {string} [params.evidencePath] - This run's evidence path.
|
|
61
|
+
* @returns {Promise<{ status: 'EXECUTED', mode: 'notify-handoff',
|
|
62
|
+
* checklist: string[], manualSyncPrompt: string, observation: object }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function executeNotifyHandoffHook(params) {
|
|
65
|
+
const { contextProjection, evidencePath } = params ?? {};
|
|
66
|
+
const checklist = renderNotifyHandoffChecklist(contextProjection, {
|
|
67
|
+
...(evidencePath !== undefined ? { evidencePath } : {}),
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
status: 'EXECUTED',
|
|
71
|
+
mode: 'notify-handoff',
|
|
72
|
+
checklist,
|
|
73
|
+
manualSyncPrompt: checklist.join('\n'),
|
|
74
|
+
observation: { mode: 'notify-handoff' },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint-level approval for requiresApproval postPublish hooks
|
|
3
|
+
* (v0.6.3 R1, design §2.7 ruling 2).
|
|
4
|
+
*
|
|
5
|
+
* A `requiresApproval: true` hook needs its own approval record binding
|
|
6
|
+
* (planDigest, hookId). The plan-level approval-record schema is NOT
|
|
7
|
+
* extended (its top level is additionalProperties: false); this module owns
|
|
8
|
+
* the separate postpublish-approval-record schema and its validation.
|
|
9
|
+
*
|
|
10
|
+
* Time semantics (24h max window, 5-minute clock-skew tolerance, expiry) are
|
|
11
|
+
* delegated wholesale to core/approval.mjs `validateApprovalTimeWindow` so
|
|
12
|
+
* both approval kinds can never drift apart. Hook config changes change the
|
|
13
|
+
* plan digest, so approvals invalidate naturally with the plan; `runId` is
|
|
14
|
+
* audit-only and never participates in binding.
|
|
15
|
+
*
|
|
16
|
+
* @module core/postpublish-approval
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import Ajv from 'ajv';
|
|
20
|
+
import addFormats from 'ajv-formats';
|
|
21
|
+
|
|
22
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
23
|
+
import { computePlanDigest } from './plan.mjs';
|
|
24
|
+
import { validateApprovalTimeWindow } from './approval.mjs';
|
|
25
|
+
import { resolvePresetRequiresApproval } from './presets.mjs';
|
|
26
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
27
|
+
|
|
28
|
+
const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
|
|
29
|
+
'schemas/postpublish-approval-record.schema.json',
|
|
30
|
+
)).toString('utf8'));
|
|
31
|
+
const postpublishApprovalAjv = new Ajv({ allErrors: true, strict: false });
|
|
32
|
+
addFormats(postpublishApprovalAjv);
|
|
33
|
+
const validatePostPublishApprovalSchema = postpublishApprovalAjv.compile(postpublishApprovalSchema);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Schema-validate a postpublish checkpoint approval record.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} approval
|
|
39
|
+
* @throws {ReleaseError} GATE_FAILED when the record violates the schema.
|
|
40
|
+
*/
|
|
41
|
+
export function validatePostPublishApprovalRecordSchema(approval) {
|
|
42
|
+
if (validatePostPublishApprovalSchema(approval)) return;
|
|
43
|
+
const errors = validatePostPublishApprovalSchema.errors ?? [];
|
|
44
|
+
throw new ReleaseError(
|
|
45
|
+
GATE_FAILED,
|
|
46
|
+
`postpublish approval record schema validation failed: ${errors.map((error) => `${error.instancePath || '/'}: ${error.message}`).join('; ')}`,
|
|
47
|
+
{ validationErrors: errors },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate a checkpoint approval against the frozen plan.
|
|
53
|
+
*
|
|
54
|
+
* Bindings enforced (all fail-closed with GATE_FAILED):
|
|
55
|
+
* - record shape (postpublish-approval-record schema; additionalProperties
|
|
56
|
+
* is false, so plan-level fields like approvedActions are rejected);
|
|
57
|
+
* - planDigest equals the computed plan digest;
|
|
58
|
+
* - hookId names a hook declared in the frozen plan's postPublish.hooks;
|
|
59
|
+
* - that hook actually declares requiresApproval: true;
|
|
60
|
+
* - the shared approval time window (24h max, 5-minute skew, unexpired).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} plan - Frozen plan (schema-valid, digest verified by caller).
|
|
63
|
+
* @param {object} approval - Parsed postpublish approval record.
|
|
64
|
+
* @param {object} [options]
|
|
65
|
+
* @param {() => string} [options.clock] - Clock function returning ISO-8601.
|
|
66
|
+
* @param {boolean} [options.requireUnexpired] - Default true.
|
|
67
|
+
* @returns {object} The approved hook declaration.
|
|
68
|
+
* @throws {ReleaseError} GATE_FAILED on any violation.
|
|
69
|
+
*/
|
|
70
|
+
export function validatePostPublishApproval(plan, approval, options = {}) {
|
|
71
|
+
validatePostPublishApprovalRecordSchema(approval);
|
|
72
|
+
|
|
73
|
+
const actualDigest = computePlanDigest(plan);
|
|
74
|
+
if (approval.planDigest !== actualDigest) {
|
|
75
|
+
throw new ReleaseError(
|
|
76
|
+
GATE_FAILED,
|
|
77
|
+
`postpublish approval planDigest mismatch: approval says ${String(approval.planDigest).slice(0, 16)}..., plan is ${actualDigest.slice(0, 16)}...`,
|
|
78
|
+
{ approvalPlanDigest: approval.planDigest, planDigest: actualDigest },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hooks = plan.postPublish?.hooks ?? [];
|
|
83
|
+
const hook = hooks.find((entry) => entry.id === approval.hookId);
|
|
84
|
+
if (!hook) {
|
|
85
|
+
throw new ReleaseError(
|
|
86
|
+
GATE_FAILED,
|
|
87
|
+
`postpublish approval names hook "${approval.hookId}" which is not declared in the frozen plan`,
|
|
88
|
+
{ hookId: approval.hookId, declaredHookIds: hooks.map((entry) => entry.id) },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
// Effective requiresApproval (§2.6 grading): preset hooks may inherit the
|
|
92
|
+
// preset-declared default (proposal-inbox git-push -> true) without an
|
|
93
|
+
// explicit declaration; command hooks carry their declared value.
|
|
94
|
+
const effectiveRequiresApproval = hook.requiresApproval
|
|
95
|
+
?? (hook.preset !== undefined ? resolvePresetRequiresApproval(hook.preset, hook.config) : false);
|
|
96
|
+
if (effectiveRequiresApproval !== true) {
|
|
97
|
+
throw new ReleaseError(
|
|
98
|
+
GATE_FAILED,
|
|
99
|
+
`postpublish approval names hook "${approval.hookId}" which does not require approval (requiresApproval is not true)`,
|
|
100
|
+
{ hookId: approval.hookId, requiresApproval: effectiveRequiresApproval ?? false },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
validateApprovalTimeWindow(approval, {
|
|
105
|
+
clock: options.clock,
|
|
106
|
+
requireUnexpired: options.requireUnexpired,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return hook;
|
|
110
|
+
}
|
package/src/core/postpublish.mjs
CHANGED
|
@@ -23,7 +23,12 @@
|
|
|
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';
|
|
27
32
|
|
|
28
33
|
/** Secret-ish environment variable denylist (R3 credential hygiene). */
|
|
29
34
|
export const ENV_ALLOWLIST_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
|
|
@@ -178,6 +183,168 @@ function validateTarget(target, index) {
|
|
|
178
183
|
}
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
function failHook(message, details = {}) {
|
|
187
|
+
throw new ReleaseError(POSTPUBLISH_HOOK_INVALID, `postPublish hook invalid: ${message}`, details);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Re-check the command-execution safety fields shared by materialize/steps
|
|
192
|
+
* and postPublish command hooks. Throws POSTPUBLISH_HOOK_INVALID (hooks) or
|
|
193
|
+
* GATE_FAILED (materialize/steps, via `fail`) depending on `failFn`.
|
|
194
|
+
*/
|
|
195
|
+
function validateCommandFields(where, hook, failFn) {
|
|
196
|
+
if (!Array.isArray(hook.command) || hook.command.length === 0) {
|
|
197
|
+
failFn(`${where}.command must be a non-empty array (shell strings are never accepted)`);
|
|
198
|
+
}
|
|
199
|
+
for (const element of hook.command) {
|
|
200
|
+
if (typeof element !== 'string' || element.length === 0) {
|
|
201
|
+
failFn(`${where}.command must contain only non-empty strings`);
|
|
202
|
+
}
|
|
203
|
+
if (/[\x00-\x1f\x7f]/.test(element)) {
|
|
204
|
+
failFn(`${where}.command contains control characters`, { element });
|
|
205
|
+
}
|
|
206
|
+
if (element.startsWith('-') && element === hook.command[0]) {
|
|
207
|
+
failFn(`${where}.command executable must not start with "-"`, { executable: element });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (hook.cwd !== undefined) {
|
|
211
|
+
if (typeof hook.cwd !== 'string' || hook.cwd.length === 0) {
|
|
212
|
+
failFn(`${where}.cwd must be a non-empty string when provided`);
|
|
213
|
+
}
|
|
214
|
+
if (hook.cwd.startsWith('/') || hook.cwd.startsWith('./') || hook.cwd.includes('..')) {
|
|
215
|
+
failFn(`${where}.cwd must be a relative path inside the execution root`, { cwd: hook.cwd });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (hook.timeoutMs !== undefined) {
|
|
219
|
+
if (!Number.isInteger(hook.timeoutMs) || hook.timeoutMs < 1000 || hook.timeoutMs > 7200000) {
|
|
220
|
+
failFn(`${where}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: hook.timeoutMs });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (hook.envAllowlist !== undefined) {
|
|
224
|
+
if (!Array.isArray(hook.envAllowlist)) {
|
|
225
|
+
failFn(`${where}.envAllowlist must be an array`);
|
|
226
|
+
}
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for (const key of hook.envAllowlist) {
|
|
229
|
+
if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key)) {
|
|
230
|
+
failFn(`${where}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
|
|
231
|
+
}
|
|
232
|
+
if (ENV_ALLOWLIST_DENYLIST.test(key)) {
|
|
233
|
+
failFn(
|
|
234
|
+
`${where}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); distribute never reads or forwards credentials`,
|
|
235
|
+
{ key },
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (seen.has(key)) {
|
|
239
|
+
failFn(`${where}.envAllowlist contains duplicate key "${key}"`);
|
|
240
|
+
}
|
|
241
|
+
seen.add(key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Validate one postPublish hooks[] entry (v0.6.3 R1). Every violation throws
|
|
248
|
+
* POSTPUBLISH_HOOK_INVALID: the hooks layer is fail-closed and never repairs.
|
|
249
|
+
*
|
|
250
|
+
* Rules:
|
|
251
|
+
* - id mandatory, /^[a-z0-9][a-z0-9._-]*$/;
|
|
252
|
+
* - preset XOR command: exactly one must be declared;
|
|
253
|
+
* - preset references must exist in `knownPresets` (R2: defaults to the
|
|
254
|
+
* built-in preset registry; an explicit empty list keeps the R1
|
|
255
|
+
* fail-closed stance);
|
|
256
|
+
* - preset configs are validated by the registry (dual addressing,
|
|
257
|
+
* marketplace/staticFiles shapes, secret scan) — POSTPUBLISH_HOOK_INVALID;
|
|
258
|
+
* - requiresApproval may tighten but never relax below the preset default;
|
|
259
|
+
* - command entries: executable+argument array safety (no shell strings);
|
|
260
|
+
* - phase: "distribute" (default) or "postVerify";
|
|
261
|
+
* - requiresApproval/blocksVerified booleans; blocksVerified: false is a
|
|
262
|
+
* preset-only permission — custom command hooks can never weaken the
|
|
263
|
+
* VERIFIED gate;
|
|
264
|
+
* - preset hooks must not declare command-hook execution fields
|
|
265
|
+
* (cwd/timeoutMs/envAllowlist); command hooks must not declare config.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} hook
|
|
268
|
+
* @param {number} index
|
|
269
|
+
* @param {object} options - { knownPresets: string[] }
|
|
270
|
+
*/
|
|
271
|
+
function validatePostPublishHookEntry(hook, index, options) {
|
|
272
|
+
const where = `hooks[${index}]`;
|
|
273
|
+
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
274
|
+
failHook(`${where} must be a non-null object`);
|
|
275
|
+
}
|
|
276
|
+
if (typeof hook.id !== 'string' || !SAFE_ID_RE.test(hook.id)) {
|
|
277
|
+
failHook(`${where}.id must match /^[a-z0-9][a-z0-9._-]*$/`, { id: hook.id });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const hasPreset = hook.preset !== undefined;
|
|
281
|
+
const hasCommand = hook.command !== undefined;
|
|
282
|
+
if (hasPreset && hasCommand) {
|
|
283
|
+
failHook(`${where}: preset and command are mutually exclusive — declare exactly one`, { id: hook.id });
|
|
284
|
+
}
|
|
285
|
+
if (!hasPreset && !hasCommand) {
|
|
286
|
+
failHook(`${where}: declare exactly one of preset or command`, { id: hook.id });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (hook.phase !== undefined && hook.phase !== 'distribute' && hook.phase !== 'postVerify') {
|
|
290
|
+
failHook(`${where}.phase must be "distribute" or "postVerify"`, { phase: hook.phase });
|
|
291
|
+
}
|
|
292
|
+
if (hook.requiresApproval !== undefined && typeof hook.requiresApproval !== 'boolean') {
|
|
293
|
+
failHook(`${where}.requiresApproval must be a boolean`, { requiresApproval: hook.requiresApproval });
|
|
294
|
+
}
|
|
295
|
+
if (hook.blocksVerified !== undefined && typeof hook.blocksVerified !== 'boolean') {
|
|
296
|
+
failHook(`${where}.blocksVerified must be a boolean`, { blocksVerified: hook.blocksVerified });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (hasPreset) {
|
|
300
|
+
if (typeof hook.preset !== 'string' || !SAFE_ID_RE.test(hook.preset)) {
|
|
301
|
+
failHook(`${where}.preset must match /^[a-z0-9][a-z0-9._-]*$/`, { preset: hook.preset });
|
|
302
|
+
}
|
|
303
|
+
// R2: the built-in preset registry is the default authority; callers may
|
|
304
|
+
// still override with an explicit list (an empty list keeps the R1
|
|
305
|
+
// fail-closed stance where every preset reference is rejected).
|
|
306
|
+
const knownPresets = options.knownPresets ?? postPublishPresetNames();
|
|
307
|
+
if (!knownPresets.includes(hook.preset)) {
|
|
308
|
+
failHook(
|
|
309
|
+
`${where}: unknown preset "${hook.preset}" — the preset registry does not contain it (fail-closed)`,
|
|
310
|
+
{ preset: hook.preset, knownPresets },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (hook.config !== undefined && (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null)) {
|
|
314
|
+
failHook(`${where}.config must be a plain object when provided`, { id: hook.id });
|
|
315
|
+
}
|
|
316
|
+
for (const field of ['cwd', 'timeoutMs', 'envAllowlist']) {
|
|
317
|
+
if (hook[field] !== undefined) {
|
|
318
|
+
failHook(`${where}: preset hooks must not declare command-hook execution field "${field}"`, { field });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Registry-driven config validation (dual addressing, marketplace block,
|
|
322
|
+
// staticFiles, secret scan) — fail-closed per preset (R2, §2.5/§2.6).
|
|
323
|
+
validatePresetHook(hook, where);
|
|
324
|
+
// requiresApproval grading (§2.6): projects may tighten (explicit true)
|
|
325
|
+
// but never relax below the preset-declared default.
|
|
326
|
+
const presetDefault = resolvePresetRequiresApproval(hook.preset, hook.config);
|
|
327
|
+
if (hook.requiresApproval === false && presetDefault === true) {
|
|
328
|
+
failHook(
|
|
329
|
+
`${where}: requiresApproval cannot be relaxed below the preset default (preset "${hook.preset}" defaults to true; declare true to tighten, never false to relax)`,
|
|
330
|
+
{ preset: hook.preset, presetDefault },
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
// Custom command hook.
|
|
335
|
+
validateCommandFields(where, hook, failHook);
|
|
336
|
+
if (hook.blocksVerified === false) {
|
|
337
|
+
failHook(
|
|
338
|
+
`${where}: custom command hooks must not declare blocksVerified: false — only presets may weaken the VERIFIED gate`,
|
|
339
|
+
{ id: hook.id },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (hook.config !== undefined) {
|
|
343
|
+
failHook(`${where}: config is a preset-only field`, { id: hook.id });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
181
348
|
/**
|
|
182
349
|
* Validate a per-unit postPublish declaration.
|
|
183
350
|
*
|
|
@@ -185,8 +352,13 @@ function validateTarget(target, index) {
|
|
|
185
352
|
* the caller's config layer; this function is the runtime re-check).
|
|
186
353
|
* @param {object} [options]
|
|
187
354
|
* @param {string} [options.unitId] - Unit id for error context.
|
|
355
|
+
* @param {string[]} [options.knownPresets] - Preset names accepted as known.
|
|
356
|
+
* Defaults to the built-in R2 preset registry (core/presets.mjs); an
|
|
357
|
+
* explicit empty list restores the R1 fail-closed stance where every
|
|
358
|
+
* preset reference is rejected.
|
|
188
359
|
* @returns {object} The validated declaration (unmodified).
|
|
189
|
-
* @throws {ReleaseError} GATE_FAILED on
|
|
360
|
+
* @throws {ReleaseError} GATE_FAILED on declaration violations;
|
|
361
|
+
* POSTPUBLISH_HOOK_INVALID on hooks[] violations (fail-closed).
|
|
190
362
|
*/
|
|
191
363
|
export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
192
364
|
const unitLabel = options.unitId ? `unit "${options.unitId}" ` : '';
|
|
@@ -210,11 +382,16 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
|
210
382
|
}
|
|
211
383
|
}
|
|
212
384
|
|
|
213
|
-
|
|
214
|
-
|
|
385
|
+
// targets: optional since v0.6.3 R1 (hooks-only declarations are legal).
|
|
386
|
+
// An explicitly empty array still fails closed (semantic lock: a present
|
|
387
|
+
// targets array must declare at least one target).
|
|
388
|
+
const targets = postPublish.targets ?? [];
|
|
389
|
+
if (postPublish.targets !== undefined
|
|
390
|
+
&& (!Array.isArray(postPublish.targets) || postPublish.targets.length === 0)) {
|
|
391
|
+
fail(`${unitLabel}targets must be a non-empty array when present; omit it entirely for a hooks-only declaration`);
|
|
215
392
|
}
|
|
216
393
|
const ids = new Set();
|
|
217
|
-
|
|
394
|
+
targets.forEach((target, index) => {
|
|
218
395
|
validateTarget(target, index);
|
|
219
396
|
if (ids.has(target.id)) {
|
|
220
397
|
fail(`${unitLabel}duplicate target id "${target.id}"`);
|
|
@@ -223,8 +400,8 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
|
223
400
|
});
|
|
224
401
|
|
|
225
402
|
// dependsOn references must exist and point at payload-mirror targets.
|
|
226
|
-
const byId = new Map(
|
|
227
|
-
for (const target of
|
|
403
|
+
const byId = new Map(targets.map((target) => [target.id, target]));
|
|
404
|
+
for (const target of targets) {
|
|
228
405
|
if (target.dependsOn === undefined) continue;
|
|
229
406
|
const dependency = byId.get(target.dependsOn);
|
|
230
407
|
if (!dependency) {
|
|
@@ -235,6 +412,27 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
|
235
412
|
}
|
|
236
413
|
}
|
|
237
414
|
|
|
415
|
+
// hooks (v0.6.3 R1): per-entry fail-closed validation + unique ids across
|
|
416
|
+
// the normalized target/hook table.
|
|
417
|
+
if (postPublish.hooks !== undefined) {
|
|
418
|
+
if (!Array.isArray(postPublish.hooks)) {
|
|
419
|
+
failHook(`${unitLabel}hooks must be an array`);
|
|
420
|
+
}
|
|
421
|
+
postPublish.hooks.forEach((hook, index) => {
|
|
422
|
+
validatePostPublishHookEntry(hook, index, options);
|
|
423
|
+
});
|
|
424
|
+
const hookIds = new Set();
|
|
425
|
+
for (const hook of postPublish.hooks) {
|
|
426
|
+
if (hookIds.has(hook.id)) {
|
|
427
|
+
failHook(`${unitLabel}duplicate hook id "${hook.id}"`);
|
|
428
|
+
}
|
|
429
|
+
hookIds.add(hook.id);
|
|
430
|
+
if (ids.has(hook.id)) {
|
|
431
|
+
failHook(`${unitLabel}hook id "${hook.id}" conflicts with target id "${hook.id}"; normalized table ids must be unique`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
238
436
|
// commitIdentity is mandatory whenever targets exist (always, here).
|
|
239
437
|
const identity = postPublish.commitIdentity;
|
|
240
438
|
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
|
|
@@ -313,3 +511,222 @@ export function orderTargetsByDependency(targets) {
|
|
|
313
511
|
}
|
|
314
512
|
return ordered;
|
|
315
513
|
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Hook normalization + context contract (v0.6.3 R1, design §2.3)
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Environment variable carrying the read-only postPublish hook context.
|
|
521
|
+
* The JSON projection is injected into the hook process environment AFTER
|
|
522
|
+
* envAllowlist filtering (core/hooks.mjs injectEnv), so declarations can
|
|
523
|
+
* neither opt out of it nor smuggle it through their allowlist.
|
|
524
|
+
*/
|
|
525
|
+
export const POSTPUBLISH_CONTEXT_ENV = 'RELEASE_SKILL_POSTPUBLISH_CONTEXT';
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Normalize one declared postPublish hook entry: apply the governance
|
|
529
|
+
* defaults (phase distribute, blocksVerified true, requiresApproval false)
|
|
530
|
+
* and tag the entry kind. Pure function; the declaration is not mutated.
|
|
531
|
+
* This normalized shape is the digest-bound display surface shown at
|
|
532
|
+
* checkpoint approval time (review N-6).
|
|
533
|
+
*
|
|
534
|
+
* @param {object} hook - Declared hook entry (validate first for safety).
|
|
535
|
+
* @returns {object} Normalized entry: { id, kind, phase, blocksVerified,
|
|
536
|
+
* requiresApproval, + kind-specific fields (command/cwd/timeoutMs/
|
|
537
|
+
* envAllowlist or preset/config) }.
|
|
538
|
+
*/
|
|
539
|
+
export function normalizePostPublishHook(hook) {
|
|
540
|
+
const kind = hook.preset !== undefined ? 'preset' : 'command';
|
|
541
|
+
// requiresApproval grading (§2.6): command hooks default false; preset
|
|
542
|
+
// hooks inherit the preset-declared default (public-write presets true,
|
|
543
|
+
// notify-handoff / proposal-inbox local-file false; validation forbids
|
|
544
|
+
// relaxing below the default, so this resolution can only ever confirm or
|
|
545
|
+
// tighten).
|
|
546
|
+
const defaultRequiresApproval = kind === 'preset'
|
|
547
|
+
? resolvePresetRequiresApproval(hook.preset, hook.config)
|
|
548
|
+
: false;
|
|
549
|
+
const normalized = {
|
|
550
|
+
id: hook.id,
|
|
551
|
+
kind,
|
|
552
|
+
...(kind === 'preset' ? { preset: hook.preset } : { command: [...hook.command] }),
|
|
553
|
+
phase: hook.phase ?? 'distribute',
|
|
554
|
+
...(hook.config !== undefined ? { config: hook.config } : {}),
|
|
555
|
+
...(kind === 'command' && hook.cwd !== undefined ? { cwd: hook.cwd } : {}),
|
|
556
|
+
...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
|
|
557
|
+
...(hook.envAllowlist !== undefined ? { envAllowlist: [...hook.envAllowlist] } : {}),
|
|
558
|
+
requiresApproval: hook.requiresApproval ?? defaultRequiresApproval,
|
|
559
|
+
blocksVerified: hook.blocksVerified ?? true,
|
|
560
|
+
};
|
|
561
|
+
return normalized;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Effective requiresApproval (§2.6 grading) for one declared hook: command
|
|
566
|
+
* hooks carry their declared value; preset hooks inherit the preset-declared
|
|
567
|
+
* default (public-write presets true; proposal-inbox graded by transport;
|
|
568
|
+
* notify-handoff / local-file false) unless explicitly tightened. Single
|
|
569
|
+
* authority shared by distribute, postverify, and the ship re-entry gate.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} hook - Declared hook entry.
|
|
572
|
+
* @returns {boolean}
|
|
573
|
+
*/
|
|
574
|
+
export function effectiveHookRequiresApproval(hook) {
|
|
575
|
+
if (hook.requiresApproval !== undefined) return hook.requiresApproval === true;
|
|
576
|
+
if (hook.preset !== undefined) return resolvePresetRequiresApproval(hook.preset, hook.config) === true;
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Build the read-only context projection injected into postPublish hooks
|
|
582
|
+
* (design §2.3). Every field comes from the frozen plan or the sealed source
|
|
583
|
+
* run — never from the live workspace.
|
|
584
|
+
*
|
|
585
|
+
* Phase distinction: `verifyEvidence` is carried ONLY for postVerify-phase
|
|
586
|
+
* hooks; for the distribute phase the key is ABSENT (not null), so hooks can
|
|
587
|
+
* distinguish the phases without trusting a mutable value.
|
|
588
|
+
*
|
|
589
|
+
* @param {object} args
|
|
590
|
+
* @param {object} args.plan - Frozen plan (digest + units + postPublish).
|
|
591
|
+
* @param {string} args.runId - Current distribute run id.
|
|
592
|
+
* @param {object} args.sourceRun - Sealed source run (finishedAt = publishedAt).
|
|
593
|
+
* @param {string} args.payloadDir - Materialized payload directory.
|
|
594
|
+
* @param {'distribute'|'postVerify'} args.phase
|
|
595
|
+
* @param {object} [args.verifyEvidence] - Verify evidence (postVerify phase).
|
|
596
|
+
* @returns {object} The context projection.
|
|
597
|
+
*/
|
|
598
|
+
export function buildPostPublishContext({ plan, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
|
|
599
|
+
const postPublish = plan.postPublish ?? {};
|
|
600
|
+
const unit = (plan.units ?? []).find((entry) => entry.id === postPublish.unitId);
|
|
601
|
+
const frozenSnapshot = unit?.frozenSnapshot ?? {};
|
|
602
|
+
return {
|
|
603
|
+
planDigest: plan.digest,
|
|
604
|
+
runId,
|
|
605
|
+
unitId: postPublish.unitId,
|
|
606
|
+
version: unit?.targetVersion,
|
|
607
|
+
tag: postPublish.tag,
|
|
608
|
+
commit: postPublish.tagCommit,
|
|
609
|
+
...(frozenSnapshot.tree !== undefined ? { tree: frozenSnapshot.tree } : {}),
|
|
610
|
+
...(frozenSnapshot.manifestDigest !== undefined ? { manifestDigest: frozenSnapshot.manifestDigest } : {}),
|
|
611
|
+
publishedAt: sourceRun?.finishedAt,
|
|
612
|
+
payloadDir,
|
|
613
|
+
...(phase === 'postVerify' && verifyEvidence !== undefined ? { verifyEvidence } : {}),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// Targets normalization (v0.6.3 R2, design §2.2)
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
/** Legacy target kind -> absorbing preset (design §2.5). */
|
|
622
|
+
const TARGET_KIND_PRESET = {
|
|
623
|
+
'payload-mirror': 'git-mirror',
|
|
624
|
+
'marketplace-index': 'marketplace-index-render',
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Normalize one legacy targets[] entry into a preset hook entry. Field-level
|
|
629
|
+
* mapping (review N-B2): remoteUrl/branch -> config.target; visibility ->
|
|
630
|
+
* config.visibility (public-write/internal-write semantics preserved);
|
|
631
|
+
* staticFiles -> config.staticFiles; marketplace -> config.marketplace;
|
|
632
|
+
* dependsOn -> hook-level dependency (runtime validates existence + acyclicity,
|
|
633
|
+
* semantics unchanged).
|
|
634
|
+
*
|
|
635
|
+
* @param {object} target - Validated postPublish target entry.
|
|
636
|
+
* @returns {object} Normalized preset hook entry.
|
|
637
|
+
*/
|
|
638
|
+
function normalizeTargetToPresetHook(target) {
|
|
639
|
+
const preset = TARGET_KIND_PRESET[target.kind];
|
|
640
|
+
return {
|
|
641
|
+
id: target.id,
|
|
642
|
+
kind: 'preset',
|
|
643
|
+
preset,
|
|
644
|
+
origin: 'target',
|
|
645
|
+
originKind: target.kind,
|
|
646
|
+
phase: 'distribute',
|
|
647
|
+
config: {
|
|
648
|
+
target: { remoteUrl: target.remoteUrl, branch: target.branch },
|
|
649
|
+
visibility: target.visibility,
|
|
650
|
+
...(target.staticFiles !== undefined
|
|
651
|
+
? { staticFiles: target.staticFiles.map((file) => ({ from: file.from, to: file.to })) }
|
|
652
|
+
: {}),
|
|
653
|
+
...(target.marketplace !== undefined
|
|
654
|
+
? { marketplace: structuredClone(target.marketplace) }
|
|
655
|
+
: {}),
|
|
656
|
+
},
|
|
657
|
+
...(target.dependsOn !== undefined ? { dependsOn: target.dependsOn } : {}),
|
|
658
|
+
requiresApproval: resolvePresetRequiresApproval(preset, {}),
|
|
659
|
+
blocksVerified: true,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Normalize a validated postPublish declaration into the unified hook table
|
|
665
|
+
* (design §2.2). Pure and deterministic: the table is a projection of the
|
|
666
|
+
* digest-bound declaration, so any targets/hooks list change changes the plan
|
|
667
|
+
* digest and invalidates existing approvals.
|
|
668
|
+
*
|
|
669
|
+
* Shape:
|
|
670
|
+
* - `preGates`: section-level gates injected ahead of all git-write hooks —
|
|
671
|
+
* assertMainVersionAhead:true becomes `assert-main-version-ahead` (current
|
|
672
|
+
* distribute semantics preserved);
|
|
673
|
+
* - `defaults`: section-level defaults injected into the relevant presets —
|
|
674
|
+
* materialize / steps / commitIdentity;
|
|
675
|
+
* - `hooks`: target-derived preset hooks first (declaration order), then the
|
|
676
|
+
* declared hooks[] entries normalized via normalizePostPublishHook.
|
|
677
|
+
*
|
|
678
|
+
* Validate the declaration BEFORE calling this (id conflicts, dependency
|
|
679
|
+
* references, and preset semantics are fail-closed there, not here).
|
|
680
|
+
*
|
|
681
|
+
* @param {object} postPublish - Validated postPublish declaration.
|
|
682
|
+
* @returns {{ preGates: object[], defaults: object, hooks: object[] }}
|
|
683
|
+
*/
|
|
684
|
+
export function normalizePostPublishDeclaration(postPublish) {
|
|
685
|
+
const preGates = postPublish.assertMainVersionAhead === true
|
|
686
|
+
? [{ gate: 'assert-main-version-ahead', before: 'git-write-hooks' }]
|
|
687
|
+
: [];
|
|
688
|
+
const defaults = {
|
|
689
|
+
materialize: structuredClone(postPublish.materialize),
|
|
690
|
+
steps: structuredClone(postPublish.steps ?? []),
|
|
691
|
+
commitIdentity: structuredClone(postPublish.commitIdentity),
|
|
692
|
+
};
|
|
693
|
+
const targetHooks = (postPublish.targets ?? []).map((target) => normalizeTargetToPresetHook(target));
|
|
694
|
+
const declaredHooks = (postPublish.hooks ?? []).map((hook) => normalizePostPublishHook(hook));
|
|
695
|
+
return { preGates, defaults, hooks: [...targetHooks, ...declaredHooks] };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Order the normalized hook table so every hook runs after its dependsOn
|
|
700
|
+
* hook, preserving declaration order among ready entries (dependency
|
|
701
|
+
* topology + declaration order). Dangling references and cycles fail closed
|
|
702
|
+
* with GATE_FAILED — references are also validated at declaration time; this
|
|
703
|
+
* guard keeps ordering independently safe.
|
|
704
|
+
*
|
|
705
|
+
* @param {object[]} hooks - Normalized hook table.
|
|
706
|
+
* @returns {object[]} Hooks in execution order.
|
|
707
|
+
*/
|
|
708
|
+
export function orderNormalizedHooks(hooks) {
|
|
709
|
+
const byId = new Map(hooks.map((hook) => [hook.id, hook]));
|
|
710
|
+
const ordered = [];
|
|
711
|
+
const placed = new Set();
|
|
712
|
+
while (ordered.length < hooks.length) {
|
|
713
|
+
let progress = false;
|
|
714
|
+
for (const hook of hooks) {
|
|
715
|
+
if (placed.has(hook.id)) continue;
|
|
716
|
+
if (hook.dependsOn !== undefined) {
|
|
717
|
+
if (!byId.has(hook.dependsOn)) {
|
|
718
|
+
fail(`hook "${hook.id}" dependsOn unknown hook "${hook.dependsOn}"`);
|
|
719
|
+
}
|
|
720
|
+
if (!placed.has(hook.dependsOn)) continue;
|
|
721
|
+
}
|
|
722
|
+
ordered.push(hook);
|
|
723
|
+
placed.add(hook.id);
|
|
724
|
+
progress = true;
|
|
725
|
+
}
|
|
726
|
+
if (!progress) {
|
|
727
|
+
const pending = hooks.filter((hook) => !placed.has(hook.id)).map((hook) => hook.id);
|
|
728
|
+
fail(`postPublish hook dependency cycle detected among: ${pending.join(', ')}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return ordered;
|
|
732
|
+
}
|