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,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-publish preset registry (v0.6.3 R2 registry / R4 complete, design
|
|
3
|
+
* §2.5/§2.6).
|
|
4
|
+
*
|
|
5
|
+
* A preset = a name + a JSON-Schema-style runtime-validated config + a
|
|
6
|
+
* release-skill built-in implementation. The registry ships with the bundle
|
|
7
|
+
* and is enumerable via `release-skill distribute --list-presets`.
|
|
8
|
+
*
|
|
9
|
+
* Entries:
|
|
10
|
+
* - `git-mirror` (implemented): absorbs the legacy `kind: payload-mirror`
|
|
11
|
+
* target; the distribute-git mirror semantics (clone -> wipe -> write ->
|
|
12
|
+
* commit -> push, NO_CHANGE idempotence, same-name tag move ->
|
|
13
|
+
* REMOTE_CONFLICT, host credential helper stance, dry-run).
|
|
14
|
+
* - `marketplace-index-render` (implemented): absorbs the legacy
|
|
15
|
+
* `kind: marketplace-index` target (renderMarketplaceIndex + mirror push).
|
|
16
|
+
* - `proposal-inbox` (implemented): both transports — git-push (R3: clone ->
|
|
17
|
+
* write incoming/<unit>-<version>.json -> commit -> push; ls-remote
|
|
18
|
+
* cross-check) and local-file (R4: same proposal into the local checkout
|
|
19
|
+
* at target.workspace; committed locally, NEVER pushed). config.target is
|
|
20
|
+
* the ONLY optional target among write-downstream presets; absent target
|
|
21
|
+
* degrades to notify-handoff behavior instead of erroring.
|
|
22
|
+
* - `notify-handoff` (implemented, R4): the zero-write floor; renders the
|
|
23
|
+
* frozen context into a deterministic downstream sync checklist in
|
|
24
|
+
* evidence; never accepts a downstream target.
|
|
25
|
+
* - `marketplace-registry-entry` (implemented, R4): direct-edit downstream
|
|
26
|
+
* registry entry update from frozen plan values + downstream gates.
|
|
27
|
+
* - `docs-refresh` (implemented, R4): refresh one or more independent docs
|
|
28
|
+
* repositories from payload mappings + docs build gates.
|
|
29
|
+
*
|
|
30
|
+
* Dual addressing (§2.5, review N-B1): write-downstream presets share
|
|
31
|
+
* `config.target` with `remoteUrl` XOR `workspace`. Both or neither ->
|
|
32
|
+
* POSTPUBLISH_HOOK_INVALID. `workspace` paths may leave the repository root
|
|
33
|
+
* (preset-level exception to the runner's cwd containment rule); declaration
|
|
34
|
+
* time rejects control characters and `.release-skill/` runtime paths, and
|
|
35
|
+
* the raw value is plan-digest-bound (auditable at approval time). The
|
|
36
|
+
* three §2.6 execution checks (realpath evidence, TOCTOU re-check, release
|
|
37
|
+
* workspace / runtime directory exclusion) are provided here for the presets
|
|
38
|
+
* that execute against workspaces.
|
|
39
|
+
*
|
|
40
|
+
* requiresApproval grading (§2.6): defaults are declared per preset — public
|
|
41
|
+
* write presets default true; proposal-inbox local-file and notify-handoff
|
|
42
|
+
* default false. proposal-inbox runtime grading follows the EFFECTIVE
|
|
43
|
+
* transport: `config.delivery ?? addressing inference` (a target.remoteUrl
|
|
44
|
+
* without delivery infers git-push, a public write -> true; a
|
|
45
|
+
* target.workspace infers local-file -> false). The target-less
|
|
46
|
+
* notify-handoff degradation is the zero-write floor and always grades false.
|
|
47
|
+
* Projects may tighten (explicit true) but never relax below the preset
|
|
48
|
+
* default.
|
|
49
|
+
*
|
|
50
|
+
* Secret scanning: preset config string values are scanned with the same
|
|
51
|
+
* fail-closed stance as the snapshot scan — private-key blocks and common
|
|
52
|
+
* token prefixes fail with POSTPUBLISH_HOOK_INVALID (values never echoed).
|
|
53
|
+
*
|
|
54
|
+
* @module core/presets
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
58
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
59
|
+
|
|
60
|
+
import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
|
|
61
|
+
|
|
62
|
+
/** Remote URL pattern shared with core/postpublish.mjs target validation. */
|
|
63
|
+
const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
|
|
64
|
+
|
|
65
|
+
/** Branch pattern (leading alphanumeric blocks option-like names). */
|
|
66
|
+
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
67
|
+
|
|
68
|
+
/** Safe relative-path shape for staticFiles entries (same as targets). */
|
|
69
|
+
function assertSafeStaticFilePath(where, field, value, failFn) {
|
|
70
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
71
|
+
failFn(`${where}.${field} must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
if (value.startsWith('/') || value.startsWith('./') || value === '.'
|
|
74
|
+
|| value.includes('..') || value.includes('\\') || value.includes(':')) {
|
|
75
|
+
failFn(`${where}.${field} is not a safe relative path`, { value });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function failHook(message, details = {}) {
|
|
80
|
+
throw new ReleaseError(POSTPUBLISH_HOOK_INVALID, `postPublish hook invalid: ${message}`, details);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertNoControlChars(label, value) {
|
|
84
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
85
|
+
failHook(`${label} contains control characters`, { label });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Secret scanning over preset config (§2.6: covers preset config + the
|
|
91
|
+
// normalized hook table, inheriting the fail-closed scan stance)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Conservative secret shapes; matched values are NEVER echoed back. */
|
|
95
|
+
const CONFIG_SECRET_PATTERNS = [
|
|
96
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
97
|
+
/\bghp_[A-Za-z0-9]{20,}\b/,
|
|
98
|
+
/\bgho_[A-Za-z0-9]{20,}\b/,
|
|
99
|
+
/\bghs_[A-Za-z0-9]{20,}\b/,
|
|
100
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
101
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
102
|
+
/\bAKIA[A-Z0-9]{12,}\b/,
|
|
103
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** Location labels are safe to report; matched secret values never are. */
|
|
107
|
+
function scanConfigValueForSecrets(pathLabel, value) {
|
|
108
|
+
if (typeof value !== 'string') return;
|
|
109
|
+
for (const pattern of CONFIG_SECRET_PATTERNS) {
|
|
110
|
+
if (pattern.test(value)) {
|
|
111
|
+
failHook(
|
|
112
|
+
`preset config carries a secret-looking value at "${pathLabel}"; credentials belong to the host keychain, never to postPublish config`,
|
|
113
|
+
{ location: pathLabel },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function scanConfigForSecrets(config, where) {
|
|
120
|
+
const walk = (node, pathLabel) => {
|
|
121
|
+
if (typeof node === 'string') {
|
|
122
|
+
scanConfigValueForSecrets(pathLabel, node);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(node)) {
|
|
126
|
+
node.forEach((item, index) => walk(item, `${pathLabel}[${index}]`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (node && typeof node === 'object') {
|
|
130
|
+
for (const [key, value] of Object.entries(node)) {
|
|
131
|
+
walk(value, pathLabel ? `${pathLabel}.${key}` : key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(config, `${where}.config`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Registry
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shared config.target validator for write-downstream presets (N-B1):
|
|
144
|
+
* remoteUrl XOR workspace, safe branch, §2.6 workspace declaration rules.
|
|
145
|
+
*/
|
|
146
|
+
function validateDownstreamTarget(where, target, { targetOptional }) {
|
|
147
|
+
if (target === undefined) {
|
|
148
|
+
if (targetOptional) return; // proposal-inbox: degrade to notify-handoff.
|
|
149
|
+
failHook(`${where}: write-downstream presets require a config.target declaration`, {});
|
|
150
|
+
}
|
|
151
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
152
|
+
failHook(`${where}.config.target must be a plain object`);
|
|
153
|
+
}
|
|
154
|
+
const hasRemoteUrl = target.remoteUrl !== undefined;
|
|
155
|
+
const hasWorkspace = target.workspace !== undefined;
|
|
156
|
+
if (hasRemoteUrl === hasWorkspace) {
|
|
157
|
+
failHook(
|
|
158
|
+
`${where}.config.target must declare exactly one of remoteUrl or workspace (dual addressing)`,
|
|
159
|
+
{ hasRemoteUrl, hasWorkspace },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (hasRemoteUrl) {
|
|
163
|
+
if (typeof target.remoteUrl !== 'string' || !REMOTE_URL_RE.test(target.remoteUrl)) {
|
|
164
|
+
failHook(`${where}.config.target.remoteUrl must be an http(s)/file URL ending in .git`, {
|
|
165
|
+
remoteUrl: target.remoteUrl,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
assertNoControlChars(`${where}.config.target.remoteUrl`, target.remoteUrl);
|
|
169
|
+
} else {
|
|
170
|
+
if (typeof target.workspace !== 'string' || target.workspace.length === 0) {
|
|
171
|
+
failHook(`${where}.config.target.workspace must be a non-empty string`);
|
|
172
|
+
}
|
|
173
|
+
assertNoControlChars(`${where}.config.target.workspace`, target.workspace);
|
|
174
|
+
// §2.6: the runtime directory is excluded already at declaration time;
|
|
175
|
+
// relative or absolute paths outside the repository root are allowed
|
|
176
|
+
// (preset-level exception), and the raw value stays plan-digest-bound.
|
|
177
|
+
const segments = target.workspace.split(/[\\/]+/);
|
|
178
|
+
if (segments.includes('.release-skill')) {
|
|
179
|
+
failHook(
|
|
180
|
+
`${where}.config.target.workspace must not point into the .release-skill/ runtime directory`,
|
|
181
|
+
{},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (typeof target.branch !== 'string' || !BRANCH_RE.test(target.branch)) {
|
|
186
|
+
failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
|
|
187
|
+
}
|
|
188
|
+
if (target.branch.includes('..') || target.branch.endsWith('.') || target.branch.endsWith('.lock')) {
|
|
189
|
+
failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Optional config.visibility: keeps the public-write/internal-write split. */
|
|
194
|
+
function validateVisibility(where, config) {
|
|
195
|
+
if (config.visibility !== undefined
|
|
196
|
+
&& config.visibility !== 'internal' && config.visibility !== 'public') {
|
|
197
|
+
failHook(`${where}.config.visibility must be "internal" or "public"`, {
|
|
198
|
+
visibility: config.visibility,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Optional config.staticFiles: identical shape to the legacy target field. */
|
|
204
|
+
function validateStaticFiles(where, config) {
|
|
205
|
+
if (config.staticFiles === undefined) return;
|
|
206
|
+
if (!Array.isArray(config.staticFiles)) {
|
|
207
|
+
failHook(`${where}.config.staticFiles must be an array`);
|
|
208
|
+
}
|
|
209
|
+
for (const [index, file] of config.staticFiles.entries()) {
|
|
210
|
+
if (!file || typeof file !== 'object' || Array.isArray(file)) {
|
|
211
|
+
failHook(`${where}.config.staticFiles[${index}] must be a plain object`);
|
|
212
|
+
}
|
|
213
|
+
assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'from', file?.from, failHook);
|
|
214
|
+
assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'to', file?.to, failHook);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateMarketplaceBlock(where, config) {
|
|
219
|
+
const marketplace = config.marketplace;
|
|
220
|
+
if (!marketplace || typeof marketplace !== 'object' || Array.isArray(marketplace)) {
|
|
221
|
+
failHook(`${where}: marketplace-index-render requires a config.marketplace block`);
|
|
222
|
+
}
|
|
223
|
+
const { form, name, owner } = marketplace;
|
|
224
|
+
if (form !== 'github' && form !== 'url') {
|
|
225
|
+
failHook(`${where}.config.marketplace.form must be "github" or "url"`, { form });
|
|
226
|
+
}
|
|
227
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
228
|
+
failHook(`${where}.config.marketplace.name must be a non-empty string`);
|
|
229
|
+
}
|
|
230
|
+
if (typeof owner !== 'string' || owner.length === 0) {
|
|
231
|
+
failHook(`${where}.config.marketplace.owner must be a non-empty string`);
|
|
232
|
+
}
|
|
233
|
+
if (marketplace.sourceRepo !== undefined
|
|
234
|
+
&& (typeof marketplace.sourceRepo !== 'string'
|
|
235
|
+
|| !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(marketplace.sourceRepo))) {
|
|
236
|
+
failHook(`${where}.config.marketplace.sourceRepo must be owner/repo when provided`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function requirePlainConfig(where, hook) {
|
|
241
|
+
if (hook.config === undefined || typeof hook.config !== 'object'
|
|
242
|
+
|| Array.isArray(hook.config) || hook.config === null) {
|
|
243
|
+
failHook(`${where}: this preset requires a config object`, {});
|
|
244
|
+
}
|
|
245
|
+
return hook.config;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* §2.3 context fields accepted as fieldsFromPlan sources: only frozen plan
|
|
250
|
+
* values may flow into a downstream registry entry.
|
|
251
|
+
*/
|
|
252
|
+
export const FIELDS_FROM_PLAN_SOURCES = new Set([
|
|
253
|
+
'unitId',
|
|
254
|
+
'version',
|
|
255
|
+
'tag',
|
|
256
|
+
'commit',
|
|
257
|
+
'tree',
|
|
258
|
+
'manifestDigest',
|
|
259
|
+
'planDigest',
|
|
260
|
+
'publishedAt',
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/** Safe registry-entry field name (also blocks prototype-pollution keys). */
|
|
264
|
+
const SAFE_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
265
|
+
const FORBIDDEN_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
266
|
+
|
|
267
|
+
/** Shape of config.fieldsFromPlan (marketplace-registry-entry). */
|
|
268
|
+
function validateFieldsFromPlan(where, config) {
|
|
269
|
+
const fieldsFromPlan = config.fieldsFromPlan;
|
|
270
|
+
if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Array.isArray(fieldsFromPlan)) {
|
|
271
|
+
failHook(`${where}.config.fieldsFromPlan must be a plain object mapping entry fields to frozen plan fields`);
|
|
272
|
+
}
|
|
273
|
+
const entries = Object.entries(fieldsFromPlan);
|
|
274
|
+
if (entries.length === 0) {
|
|
275
|
+
failHook(`${where}.config.fieldsFromPlan must map at least one entry field`);
|
|
276
|
+
}
|
|
277
|
+
for (const [entryField, sourceField] of entries) {
|
|
278
|
+
if (!SAFE_FIELD_RE.test(entryField) || FORBIDDEN_FIELDS.has(entryField)) {
|
|
279
|
+
failHook(`${where}.config.fieldsFromPlan field "${entryField}" is not a safe entry field name`, { entryField });
|
|
280
|
+
}
|
|
281
|
+
if (typeof sourceField !== 'string' || !FIELDS_FROM_PLAN_SOURCES.has(sourceField)) {
|
|
282
|
+
failHook(
|
|
283
|
+
`${where}.config.fieldsFromPlan."${entryField}" must map to a frozen plan context field (${[...FIELDS_FROM_PLAN_SOURCES].join('/')})`,
|
|
284
|
+
{ entryField, sourceField },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Secret-ish env denylist for downstream gates (mirrors postpublish.mjs). */
|
|
291
|
+
const GATE_ENV_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
|
|
292
|
+
const GATE_ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Downstream gates (marketplace-registry-entry / docs-refresh): the same
|
|
296
|
+
* fail-closed safety rules as command hooks — executable + argument arrays
|
|
297
|
+
* (never shell strings), relative cwd, timeout bounds, envAllowlist with the
|
|
298
|
+
* secret-ish denylist. Gates run inside the downstream worktree via the R1
|
|
299
|
+
* hook runner.
|
|
300
|
+
*/
|
|
301
|
+
function validateGates(where, config) {
|
|
302
|
+
if (config.gates === undefined) return;
|
|
303
|
+
if (!Array.isArray(config.gates)) {
|
|
304
|
+
failHook(`${where}.config.gates must be an array`);
|
|
305
|
+
}
|
|
306
|
+
for (const [index, gate] of config.gates.entries()) {
|
|
307
|
+
const gwhere = `${where}.config.gates[${index}]`;
|
|
308
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
309
|
+
failHook(`${gwhere} must be a plain object`);
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(gate.command) || gate.command.length === 0) {
|
|
312
|
+
failHook(`${gwhere}.command must be a non-empty array (shell strings are never accepted)`);
|
|
313
|
+
}
|
|
314
|
+
for (const element of gate.command) {
|
|
315
|
+
if (typeof element !== 'string' || element.length === 0) {
|
|
316
|
+
failHook(`${gwhere}.command must contain only non-empty strings`);
|
|
317
|
+
}
|
|
318
|
+
assertNoControlChars(`${gwhere}.command`, element);
|
|
319
|
+
if (element.startsWith('-') && element === gate.command[0]) {
|
|
320
|
+
failHook(`${gwhere}.command executable must not start with "-"`, { executable: element });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (gate.cwd !== undefined) {
|
|
324
|
+
if (typeof gate.cwd !== 'string' || gate.cwd.length === 0) {
|
|
325
|
+
failHook(`${gwhere}.cwd must be a non-empty string when provided`);
|
|
326
|
+
}
|
|
327
|
+
if (gate.cwd.startsWith('/') || gate.cwd.startsWith('./') || gate.cwd.includes('..')) {
|
|
328
|
+
failHook(`${gwhere}.cwd must be a relative path inside the downstream repository`, { cwd: gate.cwd });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (gate.timeoutMs !== undefined) {
|
|
332
|
+
if (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1000 || gate.timeoutMs > 7200000) {
|
|
333
|
+
failHook(`${gwhere}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: gate.timeoutMs });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (gate.envAllowlist !== undefined) {
|
|
337
|
+
if (!Array.isArray(gate.envAllowlist)) {
|
|
338
|
+
failHook(`${gwhere}.envAllowlist must be an array`);
|
|
339
|
+
}
|
|
340
|
+
const seen = new Set();
|
|
341
|
+
for (const key of gate.envAllowlist) {
|
|
342
|
+
if (typeof key !== 'string' || !GATE_ENV_KEY_RE.test(key)) {
|
|
343
|
+
failHook(`${gwhere}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
|
|
344
|
+
}
|
|
345
|
+
if (GATE_ENV_DENYLIST.test(key)) {
|
|
346
|
+
failHook(
|
|
347
|
+
`${gwhere}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); gates never receive credentials`,
|
|
348
|
+
{ key },
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (seen.has(key)) {
|
|
352
|
+
failHook(`${gwhere}.envAllowlist contains duplicate key "${key}"`);
|
|
353
|
+
}
|
|
354
|
+
seen.add(key);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const REGISTRY = [
|
|
361
|
+
{
|
|
362
|
+
name: 'git-mirror',
|
|
363
|
+
description:
|
|
364
|
+
'Mirror the frozen release payload to any git remote (clone -> wipe -> write -> commit -> push; NO_CHANGE idempotent; same-name tag move fails closed). Absorbs the legacy kind: payload-mirror target. Credentials come from the host git credential helper/keychain only. Targets-form only: declare it under postPublish.targets (kind: payload-mirror), not postPublish.hooks.',
|
|
365
|
+
writeDownstream: true,
|
|
366
|
+
targetOptional: false,
|
|
367
|
+
implemented: true,
|
|
368
|
+
defaultRequiresApproval: true,
|
|
369
|
+
// R4 review M-2: the executor routes this preset through the targets[]
|
|
370
|
+
// pipeline only; a hooks[] declaration would validate but never execute,
|
|
371
|
+
// so declaration validation rejects it fail-closed (targets-form only).
|
|
372
|
+
targetsFormOnly: true,
|
|
373
|
+
legacyTargetKind: 'payload-mirror',
|
|
374
|
+
validateConfig(where, hook) {
|
|
375
|
+
const config = requirePlainConfig(where, hook);
|
|
376
|
+
validateDownstreamTarget(where, config.target, { targetOptional: false });
|
|
377
|
+
validateVisibility(where, config);
|
|
378
|
+
validateStaticFiles(where, config);
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'marketplace-index-render',
|
|
383
|
+
description:
|
|
384
|
+
'Render .claude-plugin/marketplace.json from the frozen plan and push it to a downstream git remote. Absorbs the legacy kind: marketplace-index target (marketplace.form/name/owner/sourceRepo and staticFiles carry over). Targets-form only: declare it under postPublish.targets (kind: marketplace-index), not postPublish.hooks.',
|
|
385
|
+
writeDownstream: true,
|
|
386
|
+
targetOptional: false,
|
|
387
|
+
implemented: true,
|
|
388
|
+
defaultRequiresApproval: true,
|
|
389
|
+
// R4 review M-2: targets-form only (see git-mirror above).
|
|
390
|
+
targetsFormOnly: true,
|
|
391
|
+
legacyTargetKind: 'marketplace-index',
|
|
392
|
+
validateConfig(where, hook) {
|
|
393
|
+
const config = requirePlainConfig(where, hook);
|
|
394
|
+
validateDownstreamTarget(where, config.target, { targetOptional: false });
|
|
395
|
+
validateMarketplaceBlock(where, config);
|
|
396
|
+
validateVisibility(where, config);
|
|
397
|
+
validateStaticFiles(where, config);
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'proposal-inbox',
|
|
402
|
+
description:
|
|
403
|
+
'Deliver a machine-readable update proposal for autonomous downstream consumption (hub scenarios). Transports: git-push (clone -> write incoming/<unit>-<version>.json -> commit -> push; NO_CHANGE idempotent; ls-remote cross-check) and local-file (write the same proposal into the local checkout at target.workspace and commit it locally; NEVER pushed). config.target is optional: without it the preset degrades to notify-handoff behavior instead of failing.',
|
|
404
|
+
writeDownstream: true,
|
|
405
|
+
targetOptional: true,
|
|
406
|
+
// git-push transport delivered in R3, local-file transport in R4; the
|
|
407
|
+
// description names both shipped behaviors (review minor-2 honesty).
|
|
408
|
+
implemented: true,
|
|
409
|
+
// Graded by transport: git-push is public write (true); local-file and
|
|
410
|
+
// the notify-handoff degradation stay false (§2.6).
|
|
411
|
+
defaultRequiresApproval: false,
|
|
412
|
+
// Followup 7: the registry default is false, but runtime grading is
|
|
413
|
+
// STRICTER per transport — --list-presets must say so plainly. R4 review
|
|
414
|
+
// M-1: grading follows the EFFECTIVE transport (delivery ?? addressing
|
|
415
|
+
// inference), so a remoteUrl-only declaration still grades true.
|
|
416
|
+
requiresApprovalNote:
|
|
417
|
+
'runtime transport grading is stricter than this default: the effective git-push transport (declared delivery, or a remoteUrl address that infers git-push) runs as requiresApproval true (public write); local-file and the target-less notify-handoff degradation stay false',
|
|
418
|
+
validateConfig(where, hook) {
|
|
419
|
+
if (hook.config === undefined) return; // target-less degradation is legal.
|
|
420
|
+
if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
|
|
421
|
+
failHook(`${where}.config must be a plain object when provided`);
|
|
422
|
+
}
|
|
423
|
+
const config = hook.config;
|
|
424
|
+
if (config.delivery !== undefined
|
|
425
|
+
&& config.delivery !== 'local-file' && config.delivery !== 'git-push') {
|
|
426
|
+
failHook(`${where}.config.delivery must be "local-file" or "git-push"`, {
|
|
427
|
+
delivery: config.delivery,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
validateDownstreamTarget(where, config.target, { targetOptional: true });
|
|
431
|
+
// Transport/addressing coherence (R4): git-push needs a remote to push;
|
|
432
|
+
// local-file needs a local checkout to write.
|
|
433
|
+
if (config.target !== undefined && config.delivery !== undefined) {
|
|
434
|
+
if (config.delivery === 'git-push' && typeof config.target.remoteUrl !== 'string') {
|
|
435
|
+
failHook(`${where}: delivery "git-push" requires a config.target.remoteUrl address`, {});
|
|
436
|
+
}
|
|
437
|
+
if (config.delivery === 'local-file' && typeof config.target.workspace !== 'string') {
|
|
438
|
+
failHook(`${where}: delivery "local-file" requires a config.target.workspace address`, {});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'notify-handoff',
|
|
445
|
+
description:
|
|
446
|
+
'Zero-write floor: renders the frozen context into a deterministic downstream sync checklist (version/tag/sha/tree/evidence path/suggested actions) in evidence and echoes it. No writes anywhere, zero configuration, usable by any project, never takes a downstream target; every downstream scenario degrades to at least this behavior.',
|
|
447
|
+
writeDownstream: false,
|
|
448
|
+
targetOptional: true,
|
|
449
|
+
implemented: true,
|
|
450
|
+
defaultRequiresApproval: false,
|
|
451
|
+
validateConfig(where, hook) {
|
|
452
|
+
if (hook.config === undefined) return;
|
|
453
|
+
if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
|
|
454
|
+
failHook(`${where}.config must be a plain object when provided`);
|
|
455
|
+
}
|
|
456
|
+
if (hook.config.target !== undefined) {
|
|
457
|
+
failHook(
|
|
458
|
+
`${where}: notify-handoff is the zero-write floor and never declares a downstream target`,
|
|
459
|
+
{},
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: 'marketplace-registry-entry',
|
|
466
|
+
description:
|
|
467
|
+
'Direct-edit marketplace registry entry update for downstreams WITHOUT their own governance/render pipeline: locate the entry by config.entryKey inside config.registryPath, update config.fieldsFromPlan from the frozen plan values, run the declared downstream gates (argument arrays), then push (never --force). NO_CHANGE idempotent; a missing registry file or entry fails closed for human decision. Usually declared phase: postVerify. Hubs with their own governance use proposal-inbox instead.',
|
|
468
|
+
writeDownstream: true,
|
|
469
|
+
targetOptional: false,
|
|
470
|
+
implemented: true,
|
|
471
|
+
defaultRequiresApproval: true,
|
|
472
|
+
validateConfig(where, hook) {
|
|
473
|
+
const config = requirePlainConfig(where, hook);
|
|
474
|
+
validateDownstreamTarget(where, config.target, { targetOptional: false });
|
|
475
|
+
if (config.registryPath !== undefined) {
|
|
476
|
+
assertSafeStaticFilePath(`${where}.config`, 'registryPath', config.registryPath, failHook);
|
|
477
|
+
}
|
|
478
|
+
if (typeof config.entryKey !== 'string' || config.entryKey.length === 0) {
|
|
479
|
+
failHook(`${where}.config.entryKey must be a non-empty string`);
|
|
480
|
+
}
|
|
481
|
+
assertNoControlChars(`${where}.config.entryKey`, config.entryKey);
|
|
482
|
+
validateFieldsFromPlan(where, config);
|
|
483
|
+
validateGates(where, config);
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: 'docs-refresh',
|
|
488
|
+
description:
|
|
489
|
+
'Refresh one or more independent docs repositories (GitHub Pages style) from the frozen release payload: config.mappings copy payload files into each repository (an optional per-mapping versionMarker placeholder is replaced with the release version), the declared docs build gates run before each push, and every repository in config.repositories is committed and pushed (never --force). NO_CHANGE idempotent; bound to the distribute phase by its payload requirement.',
|
|
490
|
+
writeDownstream: true,
|
|
491
|
+
targetOptional: false,
|
|
492
|
+
implemented: true,
|
|
493
|
+
defaultRequiresApproval: true,
|
|
494
|
+
validateConfig(where, hook) {
|
|
495
|
+
const config = requirePlainConfig(where, hook);
|
|
496
|
+
// One declaration, many repositories (design §2.5): every entry shares
|
|
497
|
+
// the dual-addressing rules with the single-target presets.
|
|
498
|
+
if (!Array.isArray(config.repositories) || config.repositories.length === 0) {
|
|
499
|
+
failHook(`${where}.config.repositories must be a non-empty array of downstream targets`);
|
|
500
|
+
}
|
|
501
|
+
for (const [index, repository] of config.repositories.entries()) {
|
|
502
|
+
validateDownstreamTarget(`${where}.config.repositories[${index}]`, repository, { targetOptional: false });
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(config.mappings) || config.mappings.length === 0) {
|
|
505
|
+
failHook(`${where}.config.mappings must be a non-empty array`);
|
|
506
|
+
}
|
|
507
|
+
for (const [index, mapping] of config.mappings.entries()) {
|
|
508
|
+
const mwhere = `${where}.config.mappings[${index}]`;
|
|
509
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
|
|
510
|
+
failHook(`${mwhere} must be a plain object`);
|
|
511
|
+
}
|
|
512
|
+
assertSafeStaticFilePath(mwhere, 'from', mapping?.from, failHook);
|
|
513
|
+
assertSafeStaticFilePath(mwhere, 'to', mapping?.to, failHook);
|
|
514
|
+
if (mapping.versionMarker !== undefined) {
|
|
515
|
+
if (typeof mapping.versionMarker !== 'string' || mapping.versionMarker.length === 0) {
|
|
516
|
+
failHook(`${mwhere}.versionMarker must be a non-empty string when provided`);
|
|
517
|
+
}
|
|
518
|
+
assertNoControlChars(`${mwhere}.versionMarker`, mapping.versionMarker);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
validateGates(where, config);
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* List every registered preset with its enumerable metadata
|
|
528
|
+
* (`release-skill distribute --list-presets`).
|
|
529
|
+
*
|
|
530
|
+
* @returns {Array<{ name: string, description: string, writeDownstream: boolean,
|
|
531
|
+
* targetOptional: boolean, implemented: boolean, defaultRequiresApproval: boolean,
|
|
532
|
+
* requiresApprovalNote?: string, targetsFormOnly?: boolean }>}
|
|
533
|
+
*/
|
|
534
|
+
export function listPostPublishPresets() {
|
|
535
|
+
return REGISTRY.map((entry) => ({
|
|
536
|
+
name: entry.name,
|
|
537
|
+
description: entry.description,
|
|
538
|
+
writeDownstream: entry.writeDownstream,
|
|
539
|
+
targetOptional: entry.targetOptional,
|
|
540
|
+
implemented: entry.implemented,
|
|
541
|
+
defaultRequiresApproval: entry.defaultRequiresApproval,
|
|
542
|
+
...(entry.requiresApprovalNote !== undefined
|
|
543
|
+
? { requiresApprovalNote: entry.requiresApprovalNote }
|
|
544
|
+
: {}),
|
|
545
|
+
...(entry.targetsFormOnly === true ? { targetsFormOnly: true } : {}),
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Registry preset names (the knownPresets surface for declaration validation). */
|
|
550
|
+
export function postPublishPresetNames() {
|
|
551
|
+
return REGISTRY.map((entry) => entry.name);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Look up one registry entry by name (undefined when unregistered). */
|
|
555
|
+
export function getPostPublishPreset(name) {
|
|
556
|
+
return REGISTRY.find((entry) => entry.name === name);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Effective proposal-inbox transport (single authority shared by approval
|
|
561
|
+
* grading and executor routing — the two can never drift apart, R4 review
|
|
562
|
+
* M-1): `config.delivery` pins it; otherwise the addressing infers it
|
|
563
|
+
* (target.remoteUrl -> git-push, target.workspace -> local-file). Returns
|
|
564
|
+
* undefined for the target-less notify-handoff degradation.
|
|
565
|
+
*
|
|
566
|
+
* @param {object} [config] - The proposal-inbox hook config.
|
|
567
|
+
* @returns {'git-push'|'local-file'|undefined}
|
|
568
|
+
*/
|
|
569
|
+
export function resolveProposalInboxTransport(config = {}) {
|
|
570
|
+
const target = config?.target;
|
|
571
|
+
const hasTarget = target && typeof target === 'object' && !Array.isArray(target);
|
|
572
|
+
// Target-less degradation wins over a literal delivery: the executor
|
|
573
|
+
// degrades to notify-handoff BEFORE any transport resolution, so a
|
|
574
|
+
// delivery value without a target is inert (zero-write floor, R4 m-1).
|
|
575
|
+
if (!hasTarget) return undefined;
|
|
576
|
+
if (config?.delivery !== undefined) return config.delivery;
|
|
577
|
+
return typeof target.remoteUrl === 'string' ? 'git-push' : 'local-file';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Resolve the preset-declared requiresApproval default (§2.6 grading).
|
|
582
|
+
* proposal-inbox is transport-graded by the EFFECTIVE transport (declared
|
|
583
|
+
* delivery ?? addressing inference, R4 review M-1): a remoteUrl-only target
|
|
584
|
+
* infers git-push, a public write -> true; a workspace target infers
|
|
585
|
+
* local-file -> false. The target-less notify-handoff degradation is the
|
|
586
|
+
* zero-write floor and always grades false, even when a literal delivery
|
|
587
|
+
* says git-push (R4 review m-1). Unknown presets fail safe as public write
|
|
588
|
+
* (true).
|
|
589
|
+
*
|
|
590
|
+
* @param {string} presetName
|
|
591
|
+
* @param {object} [config] - The hook config (transport grading input).
|
|
592
|
+
* @returns {boolean}
|
|
593
|
+
*/
|
|
594
|
+
export function resolvePresetRequiresApproval(presetName, config = {}) {
|
|
595
|
+
const entry = getPostPublishPreset(presetName);
|
|
596
|
+
if (!entry) return true; // Fail safe: unregistered behaves like public write.
|
|
597
|
+
if (presetName === 'proposal-inbox') {
|
|
598
|
+
return resolveProposalInboxTransport(config) === 'git-push';
|
|
599
|
+
}
|
|
600
|
+
return entry.defaultRequiresApproval;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Validate one preset hook's config through the registry (fail-closed with
|
|
605
|
+
* POSTPUBLISH_HOOK_INVALID). Called by validatePostPublishDeclaration after
|
|
606
|
+
* the preset existence check; secret scanning covers the whole config tree.
|
|
607
|
+
*
|
|
608
|
+
* Only hooks[] entries reach this seam (targets[] entries validate through
|
|
609
|
+
* validateTarget), so targets-form-only presets (R4 review M-2) are rejected
|
|
610
|
+
* here: they execute through the legacy targets[] pipeline, and accepting a
|
|
611
|
+
* hooks[] declaration would validate a hook the executor can never route.
|
|
612
|
+
*
|
|
613
|
+
* @param {object} hook - The declared hook entry (id/preset/config).
|
|
614
|
+
* @param {string} where - Error-context label, e.g. `hooks[0]`.
|
|
615
|
+
*/
|
|
616
|
+
export function validatePresetHook(hook, where) {
|
|
617
|
+
const entry = getPostPublishPreset(hook.preset);
|
|
618
|
+
if (!entry) {
|
|
619
|
+
failHook(`${where}: unknown preset "${hook.preset}"`, { preset: hook.preset });
|
|
620
|
+
}
|
|
621
|
+
if (entry.targetsFormOnly === true) {
|
|
622
|
+
failHook(
|
|
623
|
+
`${where}: preset "${hook.preset}" is targets-form only — declare it under postPublish.targets as kind "${entry.legacyTargetKind}", not under postPublish.hooks (the hooks[] form of this preset is not executable in this release)`,
|
|
624
|
+
{ preset: hook.preset, legacyTargetKind: entry.legacyTargetKind },
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (hook.config !== undefined) {
|
|
628
|
+
scanConfigForSecrets(hook.config, where);
|
|
629
|
+
}
|
|
630
|
+
entry.validateConfig(where, hook);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Workspace preflight + execution checks (§2.6, three execution rules)
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
function failWorkspace(message, details = {}) {
|
|
638
|
+
throw new ReleaseError(GATE_FAILED, `preset workspace invalid: ${message}`, details);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Preflight a `config.target.workspace` before any write: the path must
|
|
643
|
+
* exist and be a git worktree (a `.git` entry), otherwise fail closed.
|
|
644
|
+
* Returns the resolved realpath for evidence and the TOCTOU re-check.
|
|
645
|
+
*
|
|
646
|
+
* @param {string} workspace - Declared workspace path (relative to `root`
|
|
647
|
+
* or absolute; may leave the repository root by preset-level exception).
|
|
648
|
+
* @param {object} options - { root: string }
|
|
649
|
+
* @returns {Promise<{ realpath: string }>}
|
|
650
|
+
*/
|
|
651
|
+
export async function preflightPresetWorkspace(workspace, { root }) {
|
|
652
|
+
const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
|
|
653
|
+
let real;
|
|
654
|
+
try {
|
|
655
|
+
real = await realpath(resolved);
|
|
656
|
+
} catch {
|
|
657
|
+
failWorkspace(`path does not resolve to an existing directory: ${workspace}`, { workspace });
|
|
658
|
+
}
|
|
659
|
+
const stats = await stat(real).catch(() => null);
|
|
660
|
+
if (!stats || !stats.isDirectory()) {
|
|
661
|
+
failWorkspace(`path is not a directory: ${workspace}`, { workspace });
|
|
662
|
+
}
|
|
663
|
+
const gitEntry = await stat(resolve(real, '.git')).catch(() => null);
|
|
664
|
+
if (!gitEntry) {
|
|
665
|
+
failWorkspace(`path is not a git worktree (no .git entry): ${workspace}`, { workspace });
|
|
666
|
+
}
|
|
667
|
+
return { realpath: real };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Execution-time workspace re-checks (§2.6, review N-2), run immediately
|
|
672
|
+
* before the preset writes:
|
|
673
|
+
* 1. the realpath is resolved again and returned for evidence recording;
|
|
674
|
+
* 2. it MUST equal the preflight realpath (TOCTOU: the path may not have
|
|
675
|
+
* been swapped for a symlink since preflight);
|
|
676
|
+
* 3. it must be neither the release workspace itself nor inside the
|
|
677
|
+
* `.release-skill/` runtime directory.
|
|
678
|
+
*
|
|
679
|
+
* @param {string} workspace - Declared workspace path.
|
|
680
|
+
* @param {object} options - { root, preflightRealpath }
|
|
681
|
+
* @returns {Promise<{ realpath: string }>}
|
|
682
|
+
*/
|
|
683
|
+
export async function assertPresetWorkspaceExecution(workspace, { root, preflightRealpath }) {
|
|
684
|
+
const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
|
|
685
|
+
let real;
|
|
686
|
+
try {
|
|
687
|
+
real = await realpath(resolved);
|
|
688
|
+
} catch {
|
|
689
|
+
failWorkspace(`path no longer resolves at execution time: ${workspace}`, { workspace });
|
|
690
|
+
}
|
|
691
|
+
if (preflightRealpath !== undefined && real !== preflightRealpath) {
|
|
692
|
+
failWorkspace(
|
|
693
|
+
`workspace realpath changed between preflight and execution (TOCTOU guard); refusing to write`,
|
|
694
|
+
{ workspace, preflightRealpath, observedRealpath: real },
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
const rootReal = await realpath(root).catch(() => null);
|
|
698
|
+
if (rootReal && real === rootReal) {
|
|
699
|
+
failWorkspace('workspace must not be the release workspace itself', { workspace });
|
|
700
|
+
}
|
|
701
|
+
const segments = real.split(/[\\/]+/);
|
|
702
|
+
if (segments.includes('.release-skill')) {
|
|
703
|
+
failWorkspace('workspace must not live inside the .release-skill/ runtime directory', { workspace });
|
|
704
|
+
}
|
|
705
|
+
return { realpath: real };
|
|
706
|
+
}
|