release-skill 0.6.3 → 0.7.1
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 +36 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +17 -23
- package/README.zh-CN.md +8 -20
- 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 +34866 -31254
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +136 -4
- package/adapters/claude/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +136 -4
- package/adapters/codex/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +136 -4
- package/adapters/kimi/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
- 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 +6 -6
- package/bin/release-skill.bundle.mjs +34866 -31254
- package/bin/rules.json +1 -1
- package/package.json +5 -4
- package/platform-manifest.json +2 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +136 -4
- package/schemas/release-project.schema.json +13 -4
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +6 -10
- package/src/commands/distribute.mjs +251 -69
- package/src/commands/postverify.mjs +81 -7
- package/src/commands/prepare.mjs +176 -3
- package/src/core/docs-refresh-preset.mjs +8 -4
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/marketplace-registry-entry.mjs +4 -3
- package/src/core/postpublish-approval.mjs +132 -1
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +26 -19
- package/src/core/preset-executor.mjs +25 -6
- package/src/core/preset-gitwrite.mjs +37 -16
- package/src/core/presets.mjs +67 -19
- package/src/core/proposal-inbox.mjs +36 -20
- package/src/core/redact.mjs +16 -3
- package/src/producers/build-adapters.mjs +28 -14
- package/src/snapshot/scan.mjs +6 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public payload managed projection (F-06 / T6).
|
|
3
|
+
*
|
|
4
|
+
* Release-domain orchestration ONLY. Full preflight, path containment,
|
|
5
|
+
* lexical classification, transactional writes, rollback, and closure
|
|
6
|
+
* digests all come from the exact Foundation version pinned by
|
|
7
|
+
* `packages/release-skill/package.json` and `pnpm-lock.yaml`
|
|
8
|
+
* `compileProjectionPlan()` / `runProjection()` by package import; strict
|
|
9
|
+
* no-follow source reads come from `skill-family-harness-node`
|
|
10
|
+
* `readFileStrict()`. This module never reimplements a containment check, a
|
|
11
|
+
* preflight, a writer, or a rollback — it supplies exactly the release
|
|
12
|
+
* semantics the Foundation mechanisms do not own:
|
|
13
|
+
*
|
|
14
|
+
* - WHAT is projected: the frozen plan's `postPublish.executionBundle.
|
|
15
|
+
* publicFiles` mapping (schema- and runtime-validated before the plan was
|
|
16
|
+
* frozen). Live project configuration is never read here or downstream —
|
|
17
|
+
* the frozen plan is the only authority, per the R1 timing contract.
|
|
18
|
+
* - WHERE the bytes come from: the detached tag worktree at the frozen
|
|
19
|
+
* tagCommit (snapshot layout — public files live at their `to` paths),
|
|
20
|
+
* read strictly (no-follow, regular-file identity, digest receipt).
|
|
21
|
+
* - WHERE the payload lands: a completely fresh `hub-payload` root inside
|
|
22
|
+
* the execution worktree. The payload root must not pre-exist; a planted
|
|
23
|
+
* symlink or stale directory at that path fails closed before any write.
|
|
24
|
+
* - HOW authority is bound: the frozen mapping bytes ride the projection
|
|
25
|
+
* plan as a `caller-bytes` authority binding (FG-3) — the projection never
|
|
26
|
+
* reads authority from the target root and never forges target-local
|
|
27
|
+
* authority facts; the Kit re-verifies the digest-bound bytes before every
|
|
28
|
+
* mutation.
|
|
29
|
+
*
|
|
30
|
+
* Ordering guarantee: mapping shape checks, strict source staging, and the
|
|
31
|
+
* pure compile step all happen BEFORE the payload root is created — lexical
|
|
32
|
+
* escapes, duplicates, and collisions refuse with zero writes and leave no
|
|
33
|
+
* partial `hub-payload` behind.
|
|
34
|
+
*
|
|
35
|
+
* @module core/postpublish-projection
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { chmod, lstat, mkdir, realpath, writeFile } from 'node:fs/promises';
|
|
39
|
+
import { dirname, join } from 'node:path';
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
buildProjectionClosure,
|
|
43
|
+
compileProjectionPlan,
|
|
44
|
+
runProjection,
|
|
45
|
+
} from 'skill-family-engineering-kit';
|
|
46
|
+
import { digestBytes, readFileStrict } from 'skill-family-harness-node';
|
|
47
|
+
|
|
48
|
+
import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
|
|
49
|
+
|
|
50
|
+
/** Mechanism identity recorded in the materialize evidence. */
|
|
51
|
+
export const PROJECTION_MECHANISM = 'foundation.engineering-kit.projection';
|
|
52
|
+
|
|
53
|
+
/** The fresh payload root staged inside the execution worktree. */
|
|
54
|
+
export const PUBLIC_PAYLOAD_DIRNAME = 'hub-payload';
|
|
55
|
+
|
|
56
|
+
/** Authority identity of the frozen publicFiles mapping. */
|
|
57
|
+
const AUTHORITY_ID = 'frozen-public-files';
|
|
58
|
+
const AUTHORITY_PATH = 'postpublish-public-files.json';
|
|
59
|
+
|
|
60
|
+
/** Ownership identity recorded for every projected payload file. */
|
|
61
|
+
const OWNER_ID = 'release-skill-postpublish';
|
|
62
|
+
|
|
63
|
+
function fail(message, details = {}) {
|
|
64
|
+
throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, `public payload projection: ${message}`, details);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stage the frozen public payload into a fresh `hub-payload` root inside the
|
|
69
|
+
* execution worktree through the Foundation managed projection.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} params
|
|
72
|
+
* @param {string} params.executionWorktreeRoot - Detached tag worktree root
|
|
73
|
+
* (frozen tagCommit checkout; snapshot layout).
|
|
74
|
+
* @param {string} params.candidateRoot - Disposable staging directory for the
|
|
75
|
+
* candidate closure (external to the payload root; caller owns cleanup).
|
|
76
|
+
* @param {object[]} params.publicFiles - Frozen plan publicFiles mapping.
|
|
77
|
+
* @returns {Promise<{payloadRoot: string, fileCount: number, mechanism: string}>}
|
|
78
|
+
* @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED on any refusal — after
|
|
79
|
+
* Foundation preflight/rollback semantics, never with a partial payload.
|
|
80
|
+
*/
|
|
81
|
+
export async function projectPublicPayload({ executionWorktreeRoot, candidateRoot, publicFiles } = {}) {
|
|
82
|
+
if (!executionWorktreeRoot || typeof executionWorktreeRoot !== 'string') {
|
|
83
|
+
throw new TypeError('projectPublicPayload: executionWorktreeRoot must be a directory path string');
|
|
84
|
+
}
|
|
85
|
+
if (!candidateRoot || typeof candidateRoot !== 'string') {
|
|
86
|
+
throw new TypeError('projectPublicPayload: candidateRoot must be a directory path string');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Domain mapping shape (schema-validated upstream; re-checked fail-
|
|
90
|
+
// closed). Lexical path rejection belongs to Foundation classification. ---
|
|
91
|
+
if (!Array.isArray(publicFiles) || publicFiles.length === 0) {
|
|
92
|
+
fail('the frozen plan carries no publicFiles mapping; the payload cannot be staged');
|
|
93
|
+
}
|
|
94
|
+
for (const [index, entry] of publicFiles.entries()) {
|
|
95
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)
|
|
96
|
+
|| typeof entry.from !== 'string' || entry.from.length === 0
|
|
97
|
+
|| typeof entry.to !== 'string' || entry.to.length === 0
|
|
98
|
+
|| entry.mode !== 'preserve') {
|
|
99
|
+
fail(`frozen publicFiles entry ${index} is not a valid preserve-mode mapping`, { index });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const worktreeReal = await realpath(executionWorktreeRoot).catch(() => null);
|
|
104
|
+
if (!worktreeReal) {
|
|
105
|
+
fail('the execution worktree does not resolve to an existing directory', { executionWorktreeRoot });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Payload target preflight: the root must not pre-exist. The tag
|
|
109
|
+
// worktree is disposable and freshly created; a planted symlink or stale
|
|
110
|
+
// directory at the payload path fails closed BEFORE any write. ---
|
|
111
|
+
const payloadPath = join(worktreeReal, PUBLIC_PAYLOAD_DIRNAME);
|
|
112
|
+
const preExisting = await lstat(payloadPath).catch(() => null);
|
|
113
|
+
if (preExisting) {
|
|
114
|
+
fail(`the payload root already exists in the tag worktree (${preExisting.isSymbolicLink() ? 'symbolic link' : 'pre-existing entry'})`, {
|
|
115
|
+
path: PUBLIC_PAYLOAD_DIRNAME,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Candidate staging: strict no-follow reads of every frozen `to` source
|
|
120
|
+
// from the frozen worktree (never the live workspace). A symlinked source
|
|
121
|
+
// is refused here — the escape target is never opened. ---
|
|
122
|
+
const stagedResources = [];
|
|
123
|
+
for (const entry of publicFiles) {
|
|
124
|
+
let receipt;
|
|
125
|
+
try {
|
|
126
|
+
receipt = await readFileStrict(executionWorktreeRoot, entry.to);
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
fail(`cannot strictly read the frozen public file "${entry.to}" from the tag worktree: ${cause?.message ?? cause}`, {
|
|
129
|
+
path: entry.to,
|
|
130
|
+
kind: cause?.details?.kind,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const stagePath = join(candidateRoot, entry.to);
|
|
134
|
+
await mkdir(dirname(stagePath), { recursive: true });
|
|
135
|
+
await writeFile(stagePath, receipt.content);
|
|
136
|
+
await chmod(stagePath, receipt.mode);
|
|
137
|
+
stagedResources.push({ path: entry.to, sha256: receipt.sha256, mode: receipt.mode });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Authority binding: the frozen mapping bytes ride the plan as a
|
|
141
|
+
// caller-bytes binding — no authority filesystem access, nothing forged
|
|
142
|
+
// into the target root. ---
|
|
143
|
+
const authorityBytes = Buffer.from(JSON.stringify(publicFiles), 'utf8');
|
|
144
|
+
|
|
145
|
+
// --- Pure compile: containment, duplicates, portable collisions, closure
|
|
146
|
+
// digests — every refusal happens before the payload root exists. ---
|
|
147
|
+
let prepared;
|
|
148
|
+
try {
|
|
149
|
+
prepared = compileProjectionPlan({
|
|
150
|
+
rootBinding: payloadPath,
|
|
151
|
+
authoritySources: [{
|
|
152
|
+
id: AUTHORITY_ID,
|
|
153
|
+
path: AUTHORITY_PATH,
|
|
154
|
+
type: 'file',
|
|
155
|
+
sha256: digestBytes(authorityBytes),
|
|
156
|
+
mode: 0o644,
|
|
157
|
+
}],
|
|
158
|
+
ownership: publicFiles.map((entry) => ({
|
|
159
|
+
path: entry.to,
|
|
160
|
+
authoritySource: AUTHORITY_ID,
|
|
161
|
+
owner: { kind: 'managed', id: OWNER_ID },
|
|
162
|
+
expect: { state: 'absent' },
|
|
163
|
+
})),
|
|
164
|
+
handwrittenPolicy: { authoritySource: AUTHORITY_ID, patterns: [] },
|
|
165
|
+
previousOwnedClosure: buildProjectionClosure([]),
|
|
166
|
+
externalCandidateClosure: buildProjectionClosure(stagedResources),
|
|
167
|
+
authorityBinding: {
|
|
168
|
+
kind: 'caller-bytes',
|
|
169
|
+
bytes: { [AUTHORITY_ID]: authorityBytes.toString('base64') },
|
|
170
|
+
freshRoot: true,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
fail(`the frozen publicFiles mapping cannot be compiled into a projection plan: ${cause?.message ?? cause}`, {
|
|
175
|
+
kind: cause?.details?.kind,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Create the payload root and re-verify its identity (TOCTOU guard:
|
|
180
|
+
// the realpath must equal the compiled rootBinding). ---
|
|
181
|
+
try {
|
|
182
|
+
await mkdir(payloadPath);
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
fail(`cannot create the fresh payload root: ${cause?.message ?? cause}`, { path: PUBLIC_PAYLOAD_DIRNAME });
|
|
185
|
+
}
|
|
186
|
+
const payloadReal = await realpath(payloadPath).catch(() => null);
|
|
187
|
+
if (!payloadReal || payloadReal !== payloadPath) {
|
|
188
|
+
fail('the payload root identity changed during creation (symbolic-link ancestor refused)', {
|
|
189
|
+
path: PUBLIC_PAYLOAD_DIRNAME,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- Foundation managed projection: complete preflight already passed;
|
|
194
|
+
// runProjection re-verifies candidate, authority, and target expectations
|
|
195
|
+
// before every mutation and restores the complete closure on failure. ---
|
|
196
|
+
try {
|
|
197
|
+
await runProjection({
|
|
198
|
+
root: payloadPath,
|
|
199
|
+
manifest: prepared.manifest,
|
|
200
|
+
candidateRoot,
|
|
201
|
+
preparedProjection: prepared,
|
|
202
|
+
});
|
|
203
|
+
} catch (cause) {
|
|
204
|
+
fail(`projection execution refused: ${cause?.message ?? cause}`, {
|
|
205
|
+
kind: cause?.details?.kind,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { payloadRoot: payloadReal, fileCount: publicFiles.length, mechanism: PROJECTION_MECHANISM };
|
|
210
|
+
}
|
package/src/core/postpublish.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
validatePresetHook,
|
|
30
30
|
resolvePresetRequiresApproval,
|
|
31
31
|
} from './presets.mjs';
|
|
32
|
+
import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
|
|
32
33
|
|
|
33
34
|
/** Secret-ish environment variable denylist (R3 credential hygiene). */
|
|
34
35
|
export const ENV_ALLOWLIST_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
|
|
@@ -39,9 +40,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
|
39
40
|
/** Safe target/step id and step name pattern. */
|
|
40
41
|
const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
41
42
|
|
|
42
|
-
/** Remote URL pattern: http(s) or file (test transport) and .git-suffixed. */
|
|
43
|
-
const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
|
|
44
|
-
|
|
45
43
|
/** Branch pattern (leading alphanumeric blocks option-like names). */
|
|
46
44
|
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
47
45
|
|
|
@@ -121,10 +119,13 @@ function validateTarget(target, index) {
|
|
|
121
119
|
if (target.kind !== 'payload-mirror' && target.kind !== 'marketplace-index') {
|
|
122
120
|
fail(`${where}.kind must be "payload-mirror" or "marketplace-index"`, { kind: target.kind });
|
|
123
121
|
}
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
|
|
123
|
+
if (!remoteUrlVerdict.ok) {
|
|
124
|
+
fail(
|
|
125
|
+
`${where}.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
|
|
126
|
+
{ reason: remoteUrlVerdict.reason },
|
|
127
|
+
);
|
|
126
128
|
}
|
|
127
|
-
assertNoControlChars(`${where}.remoteUrl`, target.remoteUrl);
|
|
128
129
|
if (target.visibility !== 'internal' && target.visibility !== 'public') {
|
|
129
130
|
fail(`${where}.visibility must be "internal" or "public"`, { visibility: target.visibility });
|
|
130
131
|
}
|
|
@@ -366,19 +367,25 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
|
|
|
366
367
|
fail(`${unitLabel}postPublish must be a non-null object`);
|
|
367
368
|
}
|
|
368
369
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
if (
|
|
381
|
-
|
|
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`);
|
|
379
|
+
}
|
|
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
|
+
}
|
|
382
389
|
}
|
|
383
390
|
}
|
|
384
391
|
|
|
@@ -35,6 +35,16 @@ import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
|
|
|
35
35
|
/**
|
|
36
36
|
* Execute one preset hook end-to-end.
|
|
37
37
|
*
|
|
38
|
+
* F-04 root split: this seam receives `releaseWorkspaceRoot` — the real
|
|
39
|
+
* project root the user releases from. It is forwarded to proposal-inbox
|
|
40
|
+
* (local-file), marketplace-registry-entry, docs-refresh and the shared
|
|
41
|
+
* workspace preflight (§2.6) as the ONLY `target.workspace` resolution basis
|
|
42
|
+
* and release-workspace write-exclusion basis. The detached frozen-tag
|
|
43
|
+
* worktree is the execution worktree (materialize/steps/custom command
|
|
44
|
+
* hooks) and is NEVER accepted here: the legacy ambiguous `root` option is
|
|
45
|
+
* rejected fail-closed, and neither root falls back onto the other through
|
|
46
|
+
* defaults.
|
|
47
|
+
*
|
|
38
48
|
* @param {object} params
|
|
39
49
|
* @param {object} params.hook - Declared hook entry (preset + config).
|
|
40
50
|
* @param {object} params.contextProjection - The §2.3 context projection of
|
|
@@ -43,8 +53,8 @@ import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
|
|
|
43
53
|
* projection for proposal documents (byte-deterministic redelivery);
|
|
44
54
|
* defaults to contextProjection.
|
|
45
55
|
* @param {object} params.commitIdentity - Frozen postPublish commitIdentity.
|
|
46
|
-
* @param {string} params.
|
|
47
|
-
*
|
|
56
|
+
* @param {string} params.releaseWorkspaceRoot - Release workspace root (the
|
|
57
|
+
* real project root; preset workspace resolution + write exclusion).
|
|
48
58
|
* @param {string} [params.evidencePath] - This run's evidence path
|
|
49
59
|
* (notify-handoff checklist rendering).
|
|
50
60
|
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
@@ -59,7 +69,7 @@ export async function executePresetHook(params) {
|
|
|
59
69
|
contextProjection,
|
|
60
70
|
proposalContextProjection,
|
|
61
71
|
commitIdentity,
|
|
62
|
-
|
|
72
|
+
releaseWorkspaceRoot,
|
|
63
73
|
evidencePath,
|
|
64
74
|
exec,
|
|
65
75
|
hookRunner,
|
|
@@ -68,6 +78,15 @@ export async function executePresetHook(params) {
|
|
|
68
78
|
if (!hook || typeof hook.preset !== 'string') {
|
|
69
79
|
throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, 'preset execution requires a preset hook entry');
|
|
70
80
|
}
|
|
81
|
+
if (params?.root !== undefined) {
|
|
82
|
+
// F-04: the ambiguous `root` is gone. Silently honoring it would let the
|
|
83
|
+
// detached execution worktree impersonate the release workspace again.
|
|
84
|
+
throw new ReleaseError(
|
|
85
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
86
|
+
'executePresetHook no longer accepts the ambiguous "root" option; pass releaseWorkspaceRoot (the real project root) explicitly — the detached tag worktree is the execution worktree for materialize/steps/custom command hooks and is never the release workspace',
|
|
87
|
+
{},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
71
90
|
const proposalProjection = proposalContextProjection ?? contextProjection;
|
|
72
91
|
|
|
73
92
|
switch (hook.preset) {
|
|
@@ -108,7 +127,7 @@ export async function executePresetHook(params) {
|
|
|
108
127
|
hook,
|
|
109
128
|
contextProjection: proposalProjection,
|
|
110
129
|
commitIdentity,
|
|
111
|
-
|
|
130
|
+
releaseWorkspaceRoot,
|
|
112
131
|
...(exec !== undefined ? { exec } : {}),
|
|
113
132
|
});
|
|
114
133
|
}
|
|
@@ -124,7 +143,7 @@ export async function executePresetHook(params) {
|
|
|
124
143
|
hook,
|
|
125
144
|
contextProjection,
|
|
126
145
|
commitIdentity,
|
|
127
|
-
|
|
146
|
+
releaseWorkspaceRoot,
|
|
128
147
|
...(exec !== undefined ? { exec } : {}),
|
|
129
148
|
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
130
149
|
});
|
|
@@ -136,7 +155,7 @@ export async function executePresetHook(params) {
|
|
|
136
155
|
contextProjection,
|
|
137
156
|
commitIdentity,
|
|
138
157
|
payloadDir,
|
|
139
|
-
|
|
158
|
+
releaseWorkspaceRoot,
|
|
140
159
|
...(exec !== undefined ? { exec } : {}),
|
|
141
160
|
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
142
161
|
});
|
|
@@ -41,6 +41,12 @@ import {
|
|
|
41
41
|
} from './errors.mjs';
|
|
42
42
|
import { preflightPresetWorkspace, assertPresetWorkspaceExecution } from './presets.mjs';
|
|
43
43
|
import { POSTPUBLISH_CONTEXT_ENV } from './postpublish.mjs';
|
|
44
|
+
import {
|
|
45
|
+
checkGitRemoteUrl,
|
|
46
|
+
describeGitRemoteUrlFailure,
|
|
47
|
+
isAllowedGitRemoteUrl,
|
|
48
|
+
redactUrlCredentialsIfPresent,
|
|
49
|
+
} from './git-url-policy.mjs';
|
|
44
50
|
|
|
45
51
|
const execFileAsync = promisify(execFileCb);
|
|
46
52
|
|
|
@@ -49,7 +55,6 @@ const TRANSFER_TIMEOUT_MS = 300_000;
|
|
|
49
55
|
const PROBE_TIMEOUT_MS = 30_000;
|
|
50
56
|
const TMP_PREFIX = 'release-skill-preset-write-';
|
|
51
57
|
|
|
52
|
-
const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
|
|
53
58
|
const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
54
59
|
const SHA_RE = /^[a-f0-9]{40}$/;
|
|
55
60
|
|
|
@@ -94,8 +99,13 @@ function hasControlChars(value) {
|
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
function assertSafeRemoteUrl(remoteUrl) {
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
const verdict = checkGitRemoteUrl(remoteUrl);
|
|
103
|
+
if (!verdict.ok) {
|
|
104
|
+
throw new ReleaseError(
|
|
105
|
+
GATE_FAILED,
|
|
106
|
+
`preset downstream remoteUrl ${describeGitRemoteUrlFailure(verdict.reason)}`,
|
|
107
|
+
{ reason: verdict.reason },
|
|
108
|
+
);
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
@@ -177,7 +187,7 @@ export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit)
|
|
|
177
187
|
throw new ReleaseError(
|
|
178
188
|
POST_PUBLISH_VERIFY_FAILED,
|
|
179
189
|
`post-push cross-check failed: downstream branch ${branch} is at ${observed ?? '<missing>'}, which disagrees with the pushed commit ${pushedCommit}`,
|
|
180
|
-
{ remoteUrl, branch, pushedCommit, observed },
|
|
190
|
+
{ remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), branch, pushedCommit, observed },
|
|
181
191
|
);
|
|
182
192
|
}
|
|
183
193
|
return observed;
|
|
@@ -198,7 +208,8 @@ export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit)
|
|
|
198
208
|
* preset's files into the worktree.
|
|
199
209
|
* @param {object[]} [params.gates] - Downstream gates (argument arrays).
|
|
200
210
|
* @param {object} [params.contextProjection] - Injected into gate env.
|
|
201
|
-
* @param {string} params.
|
|
211
|
+
* @param {string} params.releaseWorkspaceRoot - Release workspace root (the
|
|
212
|
+
* real project root; workspace addressing resolution + write exclusion).
|
|
202
213
|
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
203
214
|
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
204
215
|
* @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
|
|
@@ -215,7 +226,7 @@ export async function applyDownstreamGitChange(params) {
|
|
|
215
226
|
mutate,
|
|
216
227
|
gates = [],
|
|
217
228
|
contextProjection,
|
|
218
|
-
|
|
229
|
+
releaseWorkspaceRoot,
|
|
219
230
|
exec: execOpt,
|
|
220
231
|
hookRunner,
|
|
221
232
|
} = params ?? {};
|
|
@@ -258,12 +269,13 @@ export async function applyDownstreamGitChange(params) {
|
|
|
258
269
|
} catch (error) {
|
|
259
270
|
await rm(worktree, { recursive: true, force: true }).catch(() => {});
|
|
260
271
|
const classification = classifyNetFailure(stderrText(error));
|
|
272
|
+
const safeRemoteUrl = redactUrlCredentialsIfPresent(target.remoteUrl);
|
|
261
273
|
throw new ReleaseError(
|
|
262
274
|
classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
|
|
263
275
|
classification === 'auth'
|
|
264
|
-
? `preset downstream remote refused authentication for ${
|
|
265
|
-
: `cannot reach preset downstream remote ${
|
|
266
|
-
{ remoteUrl:
|
|
276
|
+
? `preset downstream remote refused authentication for ${safeRemoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
|
|
277
|
+
: `cannot reach preset downstream remote ${safeRemoteUrl} — start VPN / check network; delivery fails closed and never retries with credentials`,
|
|
278
|
+
{ remoteUrl: safeRemoteUrl, stderrTail: stderrTail(error) },
|
|
267
279
|
);
|
|
268
280
|
}
|
|
269
281
|
// Continue the declared branch when it exists; start a root commit otherwise.
|
|
@@ -281,9 +293,17 @@ export async function applyDownstreamGitChange(params) {
|
|
|
281
293
|
} else {
|
|
282
294
|
// Workspace addressing: §2.6 execution checks + a branch guard. The
|
|
283
295
|
// checkout is used in place (no clone); the branch is never switched.
|
|
284
|
-
|
|
296
|
+
// F-04: the resolution basis is the release workspace root — never the
|
|
297
|
+
// detached execution worktree.
|
|
298
|
+
if (typeof releaseWorkspaceRoot !== 'string' || releaseWorkspaceRoot.length === 0) {
|
|
299
|
+
throw new ReleaseError(
|
|
300
|
+
GATE_FAILED,
|
|
301
|
+
'preset downstream workspace addressing requires the release workspace root (releaseWorkspaceRoot); the detached execution worktree is never the release workspace',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const preflight = await preflightPresetWorkspace(target.workspace, { releaseWorkspaceRoot });
|
|
285
305
|
const execution = await assertPresetWorkspaceExecution(target.workspace, {
|
|
286
|
-
|
|
306
|
+
releaseWorkspaceRoot,
|
|
287
307
|
preflightRealpath: preflight.realpath,
|
|
288
308
|
});
|
|
289
309
|
worktree = execution.realpath;
|
|
@@ -403,11 +423,12 @@ export async function applyDownstreamGitChange(params) {
|
|
|
403
423
|
});
|
|
404
424
|
} catch (error) {
|
|
405
425
|
const classification = classifyNetFailure(stderrText(error));
|
|
426
|
+
const safeTargetAddress = redactUrlCredentialsIfPresent(target.remoteUrl ?? target.workspace);
|
|
406
427
|
throw new ReleaseError(
|
|
407
428
|
classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
|
|
408
429
|
classification === 'auth'
|
|
409
|
-
? `preset downstream push refused authentication for ${
|
|
410
|
-
: `preset downstream push to ${
|
|
430
|
+
? `preset downstream push refused authentication for ${safeTargetAddress}; the host keychain credential is reused and never prompted, read, or retried`
|
|
431
|
+
: `preset downstream push to ${safeTargetAddress} failed — start VPN / check network; delivery fails closed and never retries with credentials`,
|
|
411
432
|
{ branch: target.branch, stderrTail: stderrTail(error) },
|
|
412
433
|
);
|
|
413
434
|
}
|
|
@@ -429,12 +450,12 @@ export async function applyDownstreamGitChange(params) {
|
|
|
429
450
|
if (!crossCheckUrl) {
|
|
430
451
|
crossCheckSkipReason =
|
|
431
452
|
'workspace checkout declares no origin remote; the post-push ls-remote cross-check needs a remote URL';
|
|
432
|
-
} else if (!
|
|
453
|
+
} else if (!isAllowedGitRemoteUrl(crossCheckUrl)) {
|
|
433
454
|
crossCheckSkipReason =
|
|
434
|
-
'workspace origin URL is not
|
|
455
|
+
'workspace origin URL is not a policy-allowed http(s)/file URL (SSH and other transports, or credential-embedded forms, are outside the ls-remote cross-check policy); post-push cross-check skipped';
|
|
435
456
|
}
|
|
436
457
|
}
|
|
437
|
-
if (crossCheckUrl &&
|
|
458
|
+
if (crossCheckUrl && isAllowedGitRemoteUrl(crossCheckUrl)) {
|
|
438
459
|
await crossCheckBranchTip(exec, crossCheckUrl, target.branch, localCommit);
|
|
439
460
|
}
|
|
440
461
|
|
package/src/core/presets.mjs
CHANGED
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
import { realpath, stat } from 'node:fs/promises';
|
|
58
58
|
import { isAbsolute, resolve } from 'node:path';
|
|
59
59
|
|
|
60
|
-
import {
|
|
60
|
+
import { classifyPathInput } from 'skill-family-harness-node';
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
|
|
63
|
+
import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
|
|
64
64
|
|
|
65
65
|
/** Branch pattern (leading alphanumeric blocks option-like names). */
|
|
66
66
|
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
@@ -160,12 +160,13 @@ function validateDownstreamTarget(where, target, { targetOptional }) {
|
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
162
|
if (hasRemoteUrl) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
|
|
164
|
+
if (!remoteUrlVerdict.ok) {
|
|
165
|
+
failHook(
|
|
166
|
+
`${where}.config.target.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
|
|
167
|
+
{ reason: remoteUrlVerdict.reason },
|
|
168
|
+
);
|
|
167
169
|
}
|
|
168
|
-
assertNoControlChars(`${where}.config.target.remoteUrl`, target.remoteUrl);
|
|
169
170
|
} else {
|
|
170
171
|
if (typeof target.workspace !== 'string' || target.workspace.length === 0) {
|
|
171
172
|
failHook(`${where}.config.target.workspace must be a non-empty string`);
|
|
@@ -632,24 +633,61 @@ export function validatePresetHook(hook, where) {
|
|
|
632
633
|
|
|
633
634
|
// ---------------------------------------------------------------------------
|
|
634
635
|
// Workspace preflight + execution checks (§2.6, three execution rules)
|
|
636
|
+
//
|
|
637
|
+
// F-04 root split: these checks receive `releaseWorkspaceRoot` — the real
|
|
638
|
+
// project root the user releases from. It is the ONLY resolution basis for
|
|
639
|
+
// preset `config.target.workspace` and the ONLY basis for the release-
|
|
640
|
+
// workspace write exclusion. The detached frozen-tag worktree is the
|
|
641
|
+
// execution worktree (materialize/steps/custom command hooks) and must never
|
|
642
|
+
// be passed here: the two roots never fall back onto each other.
|
|
635
643
|
// ---------------------------------------------------------------------------
|
|
636
644
|
|
|
637
645
|
function failWorkspace(message, details = {}) {
|
|
638
646
|
throw new ReleaseError(GATE_FAILED, `preset workspace invalid: ${message}`, details);
|
|
639
647
|
}
|
|
640
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Resolve a declared workspace path against the release workspace root
|
|
651
|
+
* (F-04). Absolute workspaces pass through (preset-level exception); relative
|
|
652
|
+
* workspaces are lexically classified through Foundation `classifyPathInput`
|
|
653
|
+
* (ambiguous cross-platform shapes fail closed; no parallel path regex here)
|
|
654
|
+
* and resolved from `releaseWorkspaceRoot`.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} workspace - Declared workspace path.
|
|
657
|
+
* @param {string} releaseWorkspaceRoot - The real release workspace root.
|
|
658
|
+
* @returns {string} The resolved (not yet realpathed) workspace path.
|
|
659
|
+
*/
|
|
660
|
+
function resolvePresetWorkspace(workspace, releaseWorkspaceRoot) {
|
|
661
|
+
if (typeof releaseWorkspaceRoot !== 'string' || releaseWorkspaceRoot.length === 0) {
|
|
662
|
+
failWorkspace(
|
|
663
|
+
'preset workspace checks require the release workspace root (releaseWorkspaceRoot); the detached execution worktree is never the release workspace',
|
|
664
|
+
{},
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (isAbsolute(workspace)) return workspace;
|
|
668
|
+
const classification = classifyPathInput(workspace);
|
|
669
|
+
if (!classification.ok) {
|
|
670
|
+
failWorkspace('workspace is not an unambiguous path input', {
|
|
671
|
+
workspace,
|
|
672
|
+
kind: classification.kind,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return resolve(releaseWorkspaceRoot, workspace);
|
|
676
|
+
}
|
|
677
|
+
|
|
641
678
|
/**
|
|
642
679
|
* Preflight a `config.target.workspace` before any write: the path must
|
|
643
680
|
* exist and be a git worktree (a `.git` entry), otherwise fail closed.
|
|
644
681
|
* Returns the resolved realpath for evidence and the TOCTOU re-check.
|
|
645
682
|
*
|
|
646
|
-
* @param {string} workspace - Declared workspace path (relative to
|
|
647
|
-
* or absolute; may leave the
|
|
648
|
-
*
|
|
683
|
+
* @param {string} workspace - Declared workspace path (relative to
|
|
684
|
+
* `releaseWorkspaceRoot` or absolute; may leave the release workspace root
|
|
685
|
+
* by preset-level exception).
|
|
686
|
+
* @param {object} options - { releaseWorkspaceRoot: string }
|
|
649
687
|
* @returns {Promise<{ realpath: string }>}
|
|
650
688
|
*/
|
|
651
|
-
export async function preflightPresetWorkspace(workspace, {
|
|
652
|
-
const resolved =
|
|
689
|
+
export async function preflightPresetWorkspace(workspace, { releaseWorkspaceRoot }) {
|
|
690
|
+
const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
|
|
653
691
|
let real;
|
|
654
692
|
try {
|
|
655
693
|
real = await realpath(resolved);
|
|
@@ -674,14 +712,16 @@ export async function preflightPresetWorkspace(workspace, { root }) {
|
|
|
674
712
|
* 2. it MUST equal the preflight realpath (TOCTOU: the path may not have
|
|
675
713
|
* been swapped for a symlink since preflight);
|
|
676
714
|
* 3. it must be neither the release workspace itself nor inside the
|
|
677
|
-
* `.release-skill/` runtime directory.
|
|
715
|
+
* `.release-skill/` runtime directory. The release-workspace comparison
|
|
716
|
+
* uses `releaseWorkspaceRoot` (F-04): comparing against the detached
|
|
717
|
+
* execution worktree would let a preset write into the real project.
|
|
678
718
|
*
|
|
679
719
|
* @param {string} workspace - Declared workspace path.
|
|
680
|
-
* @param {object} options - {
|
|
720
|
+
* @param {object} options - { releaseWorkspaceRoot, preflightRealpath }
|
|
681
721
|
* @returns {Promise<{ realpath: string }>}
|
|
682
722
|
*/
|
|
683
|
-
export async function assertPresetWorkspaceExecution(workspace, {
|
|
684
|
-
const resolved =
|
|
723
|
+
export async function assertPresetWorkspaceExecution(workspace, { releaseWorkspaceRoot, preflightRealpath }) {
|
|
724
|
+
const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
|
|
685
725
|
let real;
|
|
686
726
|
try {
|
|
687
727
|
real = await realpath(resolved);
|
|
@@ -694,8 +734,16 @@ export async function assertPresetWorkspaceExecution(workspace, { root, prefligh
|
|
|
694
734
|
{ workspace, preflightRealpath, observedRealpath: real },
|
|
695
735
|
);
|
|
696
736
|
}
|
|
697
|
-
|
|
698
|
-
|
|
737
|
+
let rootReal;
|
|
738
|
+
try {
|
|
739
|
+
rootReal = await realpath(releaseWorkspaceRoot);
|
|
740
|
+
} catch {
|
|
741
|
+
failWorkspace(
|
|
742
|
+
'release workspace root does not resolve to an existing directory; the release-workspace write exclusion fails closed',
|
|
743
|
+
{},
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
if (real === rootReal) {
|
|
699
747
|
failWorkspace('workspace must not be the release workspace itself', { workspace });
|
|
700
748
|
}
|
|
701
749
|
const segments = real.split(/[\\/]+/);
|