release-skill 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-publish private execution bundle (F-01 / T1).
|
|
3
|
+
*
|
|
4
|
+
* Release-domain orchestration ONLY. Path classification, containment,
|
|
5
|
+
* digesting, strict reads, exclusive publication, and temporary workspaces
|
|
6
|
+
* all come from `skill-family-harness-node` (0.6.0) by package import; this
|
|
7
|
+
* module never reimplements a hash, a containment check, an atomic writer,
|
|
8
|
+
* or a temporary-workspace algorithm. It supplies exactly the release
|
|
9
|
+
* semantics the Foundation mechanisms do not own:
|
|
10
|
+
*
|
|
11
|
+
* - WHERE the frozen bytes live: digest-addressed under the SAME
|
|
12
|
+
* `.release-skill` that owns the plan authority — mechanically derived,
|
|
13
|
+
* never a caller-supplied arbitrary root:
|
|
14
|
+
*
|
|
15
|
+
* <plan authority .release-skill>/postpublish-bundles/<closure.digest>/<resource.path>
|
|
16
|
+
*
|
|
17
|
+
* - WHAT gets frozen: the closed-world `postPublish.executionFiles` manifest
|
|
18
|
+
* (parent-workspace files that post-publish commands need but the frozen
|
|
19
|
+
* tag tree does not contain; helpers must be declared explicitly). The
|
|
20
|
+
* closure is the verbatim return of `computeResourceClosure()` — sorted,
|
|
21
|
+
* deduplicated, containment-read, digested by Foundation. The frozen plan
|
|
22
|
+
* (`postPublish.executionBundle`) is the ONLY source of truth for the
|
|
23
|
+
* bundle; there is no parallel manifest and no second bundle digest —
|
|
24
|
+
* `publicFiles` ride the plan digest with everything else.
|
|
25
|
+
*
|
|
26
|
+
* - HOW the bytes return at execution time: distribute/postVerify strictly
|
|
27
|
+
* re-read every bundle file (frozen sha256 content guard + publication
|
|
28
|
+
* mode guard), recompute the closure through Foundation in a disposable
|
|
29
|
+
* workspace, compare it with the plan's frozen closure, and only then
|
|
30
|
+
* install the verified bytes into the detached tag worktree — exclusively,
|
|
31
|
+
* so a bundle entry can never shadow a frozen tag file. Any mismatch
|
|
32
|
+
* fails closed BEFORE any hook or external write.
|
|
33
|
+
*
|
|
34
|
+
* Private files execute through the tag's interpreter and never depend on
|
|
35
|
+
* live executable modes; the bundle publishes and re-verifies one fixed
|
|
36
|
+
* regular-file mode.
|
|
37
|
+
*
|
|
38
|
+
* @module core/postpublish-bundle
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { mkdir } from 'node:fs/promises';
|
|
42
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
HARNESS_ERROR_KINDS,
|
|
46
|
+
classifyPathInput,
|
|
47
|
+
computeResourceClosure,
|
|
48
|
+
publishFileExclusive,
|
|
49
|
+
readFileStrict,
|
|
50
|
+
withTemporaryWorkspace,
|
|
51
|
+
} from 'skill-family-harness-node';
|
|
52
|
+
|
|
53
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
54
|
+
|
|
55
|
+
/** Foundation mechanism identity frozen into every executionBundle. */
|
|
56
|
+
export const EXECUTION_BUNDLE_MECHANISM = 'foundation.harness.resource-closure';
|
|
57
|
+
|
|
58
|
+
/** Bundle store directory name under the plan authority `.release-skill`. */
|
|
59
|
+
export const POSTPUBLISH_BUNDLES_DIRNAME = 'postpublish-bundles';
|
|
60
|
+
|
|
61
|
+
/** Authority directory that owns plans, approvals, runs — and bundles. */
|
|
62
|
+
export const RELEASE_SKILL_DIRNAME = '.release-skill';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The single regular-file mode every bundle byte is published with and must
|
|
66
|
+
* still carry at execution time. A mode deviation is tamper evidence: the
|
|
67
|
+
* frozen closure binds content, and publication mode is this fixed policy
|
|
68
|
+
* constant — no per-file mode manifest exists.
|
|
69
|
+
*/
|
|
70
|
+
export const EXECUTION_BUNDLE_FILE_MODE = 0o644;
|
|
71
|
+
|
|
72
|
+
function fail(message, details = {}) {
|
|
73
|
+
throw new ReleaseError(GATE_FAILED, `execution bundle: ${message}`, details);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mechanically derive the bundle store root from the `.release-skill`
|
|
78
|
+
* authority directory that owns the plan. The caller never supplies an
|
|
79
|
+
* arbitrary root: the store is a fixed child of the plan's own authority
|
|
80
|
+
* directory.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} releaseSkillDir - Absolute path of a `.release-skill` dir.
|
|
83
|
+
* @returns {string} Absolute bundle store root (may not exist yet).
|
|
84
|
+
*/
|
|
85
|
+
export function bundleRootForAuthorityDir(releaseSkillDir) {
|
|
86
|
+
const absolute = resolve(releaseSkillDir);
|
|
87
|
+
if (basename(absolute) !== RELEASE_SKILL_DIRNAME) {
|
|
88
|
+
fail('bundle root must be derived from a .release-skill authority directory', { releaseSkillDir: absolute });
|
|
89
|
+
}
|
|
90
|
+
return join(absolute, POSTPUBLISH_BUNDLES_DIRNAME);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mechanically derive the bundle store root from a plan authority path:
|
|
95
|
+
* anchor on the nearest `.release-skill` ancestor of the plan file.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} planPath - Absolute path of the frozen plan.
|
|
98
|
+
* @returns {string} Absolute bundle store root.
|
|
99
|
+
*/
|
|
100
|
+
export function deriveBundleRoot(planPath) {
|
|
101
|
+
const absolute = resolve(planPath);
|
|
102
|
+
const segments = absolute.split(sep).filter(Boolean);
|
|
103
|
+
const anchorIndex = segments.lastIndexOf(RELEASE_SKILL_DIRNAME);
|
|
104
|
+
if (anchorIndex < 0) {
|
|
105
|
+
fail('plan path has no .release-skill ancestor; cannot derive the bundle store root', { planPath: absolute });
|
|
106
|
+
}
|
|
107
|
+
const prefix = absolute.startsWith(sep) ? sep : '';
|
|
108
|
+
const releaseSkillDir = prefix + join(...segments.slice(0, anchorIndex + 1));
|
|
109
|
+
return bundleRootForAuthorityDir(releaseSkillDir);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Freeze the private execution bundle for one postPublish declaration.
|
|
114
|
+
*
|
|
115
|
+
* Order of operations (all before the plan is written):
|
|
116
|
+
* 1. Foundation path classification for every declared entry (no parallel
|
|
117
|
+
* path regex; ambiguous inputs never reach the filesystem);
|
|
118
|
+
* 2. `computeResourceClosure()` — Foundation sorts, deduplicates,
|
|
119
|
+
* containment-reads (every root-escape class rejected, root-internal
|
|
120
|
+
* ancestor aliases are path aliases, not escapes), and digests; the
|
|
121
|
+
* return value is frozen VERBATIM;
|
|
122
|
+
* 3. strict re-read of every entry bound to its frozen sha256 (any
|
|
123
|
+
* concurrent drift fails closed; a symbolic-link entry is never readable
|
|
124
|
+
* — Foundation no-follow), then exclusive publication of the exact bytes
|
|
125
|
+
* under `<bundleRoot>/<closure.digest>/<resource.path>`. Re-preparing
|
|
126
|
+
* identical bytes is idempotent; divergent bytes on an occupied
|
|
127
|
+
* digest-addressed slot fail closed.
|
|
128
|
+
*
|
|
129
|
+
* Symbolic-link policy is fully delegated to Foundation (2026-08-21 handoff
|
|
130
|
+
* ruling C): anything escaping the workspace root is rejected by closure
|
|
131
|
+
* containment, a leaf symbolic link is rejected by `readFileStrict`
|
|
132
|
+
* (no-follow), and ordinary-file identity and digests stay bound.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} params
|
|
135
|
+
* @param {string} params.workspaceRoot - Release workspace root.
|
|
136
|
+
* @param {string} params.releaseSkillDir - `.release-skill` authority dir
|
|
137
|
+
* that will own the frozen plan (mechanical bundle-root anchor).
|
|
138
|
+
* @param {string[]} [params.executionFiles] - Closed-world manifest.
|
|
139
|
+
* @param {object[]} [params.publicFiles] - Release-unit public projection
|
|
140
|
+
* frozen into the plan alongside the closure.
|
|
141
|
+
* @returns {Promise<{foundationMechanism: string, closure: object, publicFiles: object[]}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function freezeExecutionBundle({ workspaceRoot, releaseSkillDir, executionFiles = [], publicFiles = [] } = {}) {
|
|
144
|
+
if (!workspaceRoot || typeof workspaceRoot !== 'string') {
|
|
145
|
+
throw new TypeError('freezeExecutionBundle: workspaceRoot must be a directory path string');
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(executionFiles)) {
|
|
148
|
+
throw new TypeError('freezeExecutionBundle: executionFiles must be an array');
|
|
149
|
+
}
|
|
150
|
+
for (const relPath of executionFiles) {
|
|
151
|
+
const classification = classifyPathInput(relPath);
|
|
152
|
+
if (!classification.ok) {
|
|
153
|
+
fail(`executionFiles entry is not a safe workspace-relative path (kind: ${classification.kind})`, {
|
|
154
|
+
path: typeof relPath === 'string' ? relPath : typeof relPath,
|
|
155
|
+
kind: classification.kind,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Foundation closure: sort, dedupe, containment reads, sha256 — verbatim.
|
|
161
|
+
// Thin adapter mapping only: Foundation mechanism errors (lexical
|
|
162
|
+
// traversal, symlink/realpath escapes, missing inputs) surface as
|
|
163
|
+
// release-domain GATE_FAILED without reimplementing any mechanism.
|
|
164
|
+
let closure;
|
|
165
|
+
try {
|
|
166
|
+
closure = await computeResourceClosure({
|
|
167
|
+
root: workspaceRoot,
|
|
168
|
+
resources: executionFiles.map((path) => ({ path, role: 'input' })),
|
|
169
|
+
});
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
if (cause instanceof ReleaseError) throw cause;
|
|
172
|
+
fail(`cannot compute the executionFiles closure: ${cause?.message ?? cause}`, {
|
|
173
|
+
kind: cause?.details?.kind,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bundleRoot = bundleRootForAuthorityDir(releaseSkillDir);
|
|
178
|
+
await mkdir(bundleRoot, { recursive: true });
|
|
179
|
+
|
|
180
|
+
for (const resource of closure.resources) {
|
|
181
|
+
// Strict re-read bound to the frozen digest: any byte drift between the
|
|
182
|
+
// closure computation and the publication fails closed.
|
|
183
|
+
let receipt;
|
|
184
|
+
try {
|
|
185
|
+
receipt = await readFileStrict(workspaceRoot, resource.path, { expectedSha256: resource.sha256 });
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
fail(`cannot strictly read executionFiles entry for publication: ${cause?.message ?? cause}`, {
|
|
188
|
+
path: resource.path,
|
|
189
|
+
kind: cause?.details?.kind,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const relTarget = join(closure.digest, resource.path);
|
|
193
|
+
try {
|
|
194
|
+
await publishFileExclusive(bundleRoot, relTarget, receipt.content, {
|
|
195
|
+
mode: EXECUTION_BUNDLE_FILE_MODE,
|
|
196
|
+
createParents: true,
|
|
197
|
+
});
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
|
|
200
|
+
// Idempotent same-bytes re-prepare: the existing slot must carry the
|
|
201
|
+
// exact frozen bytes at the publication mode, verified strictly.
|
|
202
|
+
try {
|
|
203
|
+
const existing = await readFileStrict(bundleRoot, relTarget, { expectedSha256: resource.sha256 });
|
|
204
|
+
if (existing.mode !== EXECUTION_BUNDLE_FILE_MODE) {
|
|
205
|
+
fail('existing bundle file carries an unexpected mode', { path: resource.path, mode: existing.mode });
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
} catch (verifyCause) {
|
|
209
|
+
fail(`bundle slot already exists with divergent bytes: ${verifyCause?.message ?? verifyCause}`, {
|
|
210
|
+
path: resource.path,
|
|
211
|
+
kind: verifyCause?.details?.kind,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
fail(`cannot publish bundle bytes: ${cause?.message ?? cause}`, {
|
|
216
|
+
path: resource.path,
|
|
217
|
+
kind: cause?.details?.kind,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
foundationMechanism: EXECUTION_BUNDLE_MECHANISM,
|
|
224
|
+
closure,
|
|
225
|
+
publicFiles: structuredClone(publicFiles),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Field-by-field closure comparison (the recomputed Foundation closure must
|
|
231
|
+
* equal the frozen plan closure; the digest alone is compared first).
|
|
232
|
+
*/
|
|
233
|
+
function sameClosureResources(actual, expected) {
|
|
234
|
+
if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) return false;
|
|
235
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
236
|
+
const left = actual[index] ?? {};
|
|
237
|
+
const right = expected[index] ?? {};
|
|
238
|
+
if (left.path !== right.path || left.role !== right.role
|
|
239
|
+
|| left.exists !== right.exists || left.sha256 !== right.sha256) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Re-verify the frozen execution bundle and install ONLY the verified bytes
|
|
248
|
+
* into the detached tag worktree. Runs BEFORE any hook or external write.
|
|
249
|
+
*
|
|
250
|
+
* Steps:
|
|
251
|
+
* 1. Strictly read every frozen resource from the digest-addressed store
|
|
252
|
+
* (`readFileStrict` with the frozen sha256 content guard); the file mode
|
|
253
|
+
* must still equal the publication mode — any deviation is tamper;
|
|
254
|
+
* 2. recompute the closure through Foundation in a disposable workspace and
|
|
255
|
+
* compare it (digest + resources) with the plan's frozen closure;
|
|
256
|
+
* 3. exclusively publish the verified bytes into the worktree — an occupied
|
|
257
|
+
* target means the bundle would shadow a frozen tag file and fails closed.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} params
|
|
260
|
+
* @param {object} params.plan - Frozen plan (postPublish.executionBundle).
|
|
261
|
+
* @param {string} params.planPath - Absolute plan authority path (mechanical
|
|
262
|
+
* bundle-root anchor).
|
|
263
|
+
* @param {string} params.worktreePath - Detached tag worktree root.
|
|
264
|
+
* @returns {Promise<{installed: string[]}>} Installed relative paths.
|
|
265
|
+
*/
|
|
266
|
+
export async function verifyAndInstallExecutionBundle({ plan, planPath, worktreePath } = {}) {
|
|
267
|
+
const bundle = plan?.postPublish?.executionBundle;
|
|
268
|
+
if (!bundle) return { installed: [] };
|
|
269
|
+
const closure = bundle.closure;
|
|
270
|
+
if (!closure || !Array.isArray(closure.resources) || typeof closure.digest !== 'string') {
|
|
271
|
+
fail('plan carries a malformed executionBundle closure');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const bundleRoot = deriveBundleRoot(planPath);
|
|
275
|
+
const bytesByPath = new Map();
|
|
276
|
+
for (const resource of closure.resources) {
|
|
277
|
+
if (!resource || resource.role !== 'input' || resource.exists !== true || typeof resource.sha256 !== 'string') {
|
|
278
|
+
fail('executionBundle closure resources must be existing input resources with frozen digests', {
|
|
279
|
+
path: resource?.path,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
let receipt;
|
|
283
|
+
try {
|
|
284
|
+
receipt = await readFileStrict(bundleRoot, join(closure.digest, resource.path), {
|
|
285
|
+
expectedSha256: resource.sha256,
|
|
286
|
+
});
|
|
287
|
+
} catch (cause) {
|
|
288
|
+
fail(`bundle resource is missing or drifted from the frozen closure: ${cause?.message ?? cause}`, {
|
|
289
|
+
path: resource.path,
|
|
290
|
+
kind: cause?.details?.kind,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (receipt.mode !== EXECUTION_BUNDLE_FILE_MODE) {
|
|
294
|
+
fail(`bundle resource mode was tampered (expected ${EXECUTION_BUNDLE_FILE_MODE.toString(8)}, got ${receipt.mode.toString(8)})`, {
|
|
295
|
+
path: resource.path,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
bytesByPath.set(resource.path, receipt.content);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Foundation recomputation in a disposable workspace: any inconsistency
|
|
302
|
+
// fails before a hook or an external write.
|
|
303
|
+
try {
|
|
304
|
+
await withTemporaryWorkspace(async (workspace) => {
|
|
305
|
+
for (const resource of closure.resources) {
|
|
306
|
+
await workspace.writeFile(resource.path, bytesByPath.get(resource.path));
|
|
307
|
+
}
|
|
308
|
+
const recomputed = await computeResourceClosure({
|
|
309
|
+
root: workspace.root,
|
|
310
|
+
resources: closure.resources.map((resource) => ({ path: resource.path, role: resource.role })),
|
|
311
|
+
});
|
|
312
|
+
if (recomputed.digest !== closure.digest || !sameClosureResources(recomputed.resources, closure.resources)) {
|
|
313
|
+
fail('recomputed bundle closure differs from the frozen plan closure');
|
|
314
|
+
}
|
|
315
|
+
}, { prefix: 'rs-execution-bundle-verify-' });
|
|
316
|
+
} catch (cause) {
|
|
317
|
+
if (cause instanceof ReleaseError) throw cause;
|
|
318
|
+
fail(`bundle closure recomputation failed: ${cause?.message ?? cause}`, { kind: cause?.details?.kind });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Install verified bytes into the tag worktree — exclusively: the bundle
|
|
322
|
+
// must never overwrite (shadow) a file that already belongs to the tag.
|
|
323
|
+
const installed = [];
|
|
324
|
+
for (const resource of closure.resources) {
|
|
325
|
+
try {
|
|
326
|
+
await publishFileExclusive(worktreePath, resource.path, bytesByPath.get(resource.path), {
|
|
327
|
+
mode: EXECUTION_BUNDLE_FILE_MODE,
|
|
328
|
+
createParents: true,
|
|
329
|
+
});
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
if (cause?.details?.kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
|
|
332
|
+
fail(`execution bundle would overwrite a frozen tag file: ${resource.path}`, { path: resource.path });
|
|
333
|
+
}
|
|
334
|
+
fail(`cannot install bundle bytes into the tag worktree: ${cause?.message ?? cause}`, {
|
|
335
|
+
path: resource.path,
|
|
336
|
+
kind: cause?.details?.kind,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
installed.push(resource.path);
|
|
340
|
+
}
|
|
341
|
+
return { installed };
|
|
342
|
+
}
|
|
@@ -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
|
+
}
|