release-skill 0.1.1 → 0.1.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/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/INSTALL.md +179 -5
- package/INSTALL.zh-CN.md +320 -0
- package/README.md +347 -67
- package/README.zh-CN.md +318 -59
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/skills/release-help/SKILL.md +7 -4
- package/adapters/claude/skills/release-prepare/SKILL.md +11 -1
- package/adapters/claude/skills/release-publish/SKILL.md +6 -3
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/claude/skills/release-setup/SKILL.md +111 -0
- package/adapters/claude/skills/release-verify/SKILL.md +5 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/skills/release-help/SKILL.md +7 -4
- package/adapters/codex/skills/release-prepare/SKILL.md +11 -1
- package/adapters/codex/skills/release-publish/SKILL.md +6 -3
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/skills/release-setup/SKILL.md +111 -0
- package/adapters/codex/skills/release-verify/SKILL.md +5 -2
- package/bin/release-skill.mjs +65 -9
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +22 -2
- package/native/safe-write/src/safe_write.cc +11 -2
- package/package.json +3 -1
- package/references/02-project-config.md +54 -3
- package/references/05-evidence-and-errors.md +6 -2
- package/schemas/release-plan.schema.json +550 -65
- package/schemas/release-project.schema.json +398 -29
- package/schemas/release-run.schema.json +165 -18
- package/skills/release-help/SKILL.md +7 -4
- package/skills/release-prepare/SKILL.md +11 -1
- package/skills/release-publish/SKILL.md +6 -3
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills/release-setup/SKILL.md +111 -0
- package/skills/release-verify/SKILL.md +5 -2
- package/skills-src/release-help/SKILL.md +7 -4
- package/skills-src/release-prepare/SKILL.md +11 -1
- package/skills-src/release-publish/SKILL.md +6 -3
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-setup/SKILL.md +111 -0
- package/skills-src/release-verify/SKILL.md +5 -2
- package/src/adapters/contract.mjs +3 -0
- package/src/adapters/git-github.mjs +84 -2
- package/src/adapters/plugin-marketplace.mjs +65 -21
- package/src/adapters/push-snapshot.mjs +84 -17
- package/src/commands/prepare.mjs +223 -20
- package/src/commands/publish.mjs +45 -0
- package/src/commands/reconcile.mjs +152 -0
- package/src/commands/setup.mjs +886 -0
- package/src/commands/verify.mjs +122 -26
- package/src/core/config.mjs +34 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/plan.mjs +123 -0
- package/src/core/previous-public-baseline.mjs +21 -1
- package/src/core/verification-gates.mjs +451 -0
- package/src/snapshot/frozen.mjs +89 -5
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic project verification gates.
|
|
3
|
+
*
|
|
4
|
+
* Gates are local checks only. They use spawn without a shell, receive a
|
|
5
|
+
* minimal environment, and never participate in remote publication writes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from 'node:fs/promises';
|
|
11
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
14
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
15
|
+
import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
|
|
16
|
+
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
17
|
+
|
|
18
|
+
const OUTPUT_LIMIT_BYTES = 1024 * 1024;
|
|
19
|
+
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
20
|
+
const PLATFORM_ENV = process.platform === 'win32'
|
|
21
|
+
? ['PATH', 'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP']
|
|
22
|
+
: ['PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR'];
|
|
23
|
+
|
|
24
|
+
function gateError(gate, message, details = {}) {
|
|
25
|
+
return new ReleaseError(GATE_FAILED, `verification gate "${gate?.id ?? 'unknown'}" ${message}`, {
|
|
26
|
+
gateId: gate?.id,
|
|
27
|
+
...details,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isInside(parent, candidate) {
|
|
32
|
+
const rel = relative(parent, candidate);
|
|
33
|
+
const separator = process.platform === 'win32' ? '\\' : '/';
|
|
34
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${separator}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchesSubset(actual, expected) {
|
|
38
|
+
if (expected === null || typeof expected !== 'object' || Array.isArray(expected)) {
|
|
39
|
+
return actual === expected;
|
|
40
|
+
}
|
|
41
|
+
if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false;
|
|
42
|
+
return Object.entries(expected).every(([key, value]) => (
|
|
43
|
+
Object.hasOwn(actual, key) && matchesSubset(actual[key], value)
|
|
44
|
+
));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateGate(gate) {
|
|
48
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
49
|
+
throw gateError(gate, 'must be an object');
|
|
50
|
+
}
|
|
51
|
+
if (typeof gate.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(gate.id)) {
|
|
52
|
+
throw gateError(gate, 'has an invalid id');
|
|
53
|
+
}
|
|
54
|
+
if (!['snapshot-verify', 'consumer-verify'].includes(gate.phase)) {
|
|
55
|
+
throw gateError(gate, 'has an invalid phase');
|
|
56
|
+
}
|
|
57
|
+
if (!gate.scope || typeof gate.scope.unit !== 'string') {
|
|
58
|
+
throw gateError(gate, 'must declare scope.unit');
|
|
59
|
+
}
|
|
60
|
+
if (gate.phase === 'consumer-verify' && !['npm', 'claude-plugin', 'codex-plugin'].includes(gate.scope.distribution)) {
|
|
61
|
+
throw gateError(gate, 'consumer-verify must declare a supported scope.distribution');
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(gate.command) || gate.command.length === 0 || gate.command.some((value) => typeof value !== 'string')) {
|
|
64
|
+
throw gateError(gate, 'command must be a non-empty string array');
|
|
65
|
+
}
|
|
66
|
+
if (gate.cwd !== undefined && typeof gate.cwd !== 'string') {
|
|
67
|
+
throw gateError(gate, 'cwd must be a string');
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1 || gate.timeoutMs > 7_200_000) {
|
|
70
|
+
throw gateError(gate, 'timeoutMs must be an integer between 1 and 7200000');
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(gate.envAllowlist) || gate.envAllowlist.some((key) => typeof key !== 'string' || !ENV_KEY_PATTERN.test(key))) {
|
|
73
|
+
throw gateError(gate, 'envAllowlist must contain uppercase environment names');
|
|
74
|
+
}
|
|
75
|
+
if (gate.expectedJson !== undefined && (
|
|
76
|
+
!gate.expectedJson || typeof gate.expectedJson !== 'object' || Array.isArray(gate.expectedJson)
|
|
77
|
+
)) {
|
|
78
|
+
throw gateError(gate, 'expectedJson must be an object');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function filteredEnv(allowlist, suppliedEnv, fixedEnv = {}) {
|
|
83
|
+
const result = {};
|
|
84
|
+
for (const key of PLATFORM_ENV) {
|
|
85
|
+
if (process.env[key] !== undefined) result[key] = process.env[key];
|
|
86
|
+
}
|
|
87
|
+
for (const key of allowlist) {
|
|
88
|
+
if (suppliedEnv?.[key] !== undefined) result[key] = String(suppliedEnv[key]);
|
|
89
|
+
}
|
|
90
|
+
for (const [key, value] of Object.entries(fixedEnv)) {
|
|
91
|
+
if (value !== undefined) result[key] = String(value);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveSafeCwd(executionRoot, cwd, gate) {
|
|
97
|
+
const rootStat = await lstat(executionRoot).catch((error) => {
|
|
98
|
+
throw gateError(gate, 'execution root is missing', { cause: error.code });
|
|
99
|
+
});
|
|
100
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
101
|
+
throw gateError(gate, 'execution root must be a real directory');
|
|
102
|
+
}
|
|
103
|
+
const rootReal = await realpath(executionRoot);
|
|
104
|
+
const lexical = resolve(rootReal, cwd ?? '.');
|
|
105
|
+
if (!isInside(rootReal, lexical)) throw gateError(gate, 'cwd escapes the execution root');
|
|
106
|
+
|
|
107
|
+
const rel = relative(rootReal, lexical);
|
|
108
|
+
let current = rootReal;
|
|
109
|
+
for (const segment of rel.split(/[\\/]/).filter(Boolean)) {
|
|
110
|
+
current = join(current, segment);
|
|
111
|
+
const stat = await lstat(current).catch((error) => {
|
|
112
|
+
throw gateError(gate, 'cwd does not exist', { cause: error.code });
|
|
113
|
+
});
|
|
114
|
+
if (stat.isSymbolicLink()) throw gateError(gate, 'cwd contains a symlink');
|
|
115
|
+
}
|
|
116
|
+
const physical = await realpath(lexical);
|
|
117
|
+
if (!isInside(rootReal, physical)) throw gateError(gate, 'cwd resolves outside the execution root');
|
|
118
|
+
return physical;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function outputSummary(value) {
|
|
122
|
+
const text = value ?? '';
|
|
123
|
+
return {
|
|
124
|
+
bytes: Buffer.byteLength(text),
|
|
125
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function executeGateProcess(executable, args, options) {
|
|
130
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
131
|
+
const useProcessGroup = process.platform !== 'win32';
|
|
132
|
+
const child = spawn(executable, args, {
|
|
133
|
+
cwd: options.cwd,
|
|
134
|
+
env: options.env,
|
|
135
|
+
shell: false,
|
|
136
|
+
detached: useProcessGroup,
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
});
|
|
139
|
+
const stdoutChunks = [];
|
|
140
|
+
const stderrChunks = [];
|
|
141
|
+
let stdoutBytes = 0;
|
|
142
|
+
let stderrBytes = 0;
|
|
143
|
+
let terminationReason = null;
|
|
144
|
+
let hardKillTimer = null;
|
|
145
|
+
|
|
146
|
+
const signalTree = (signal) => {
|
|
147
|
+
try {
|
|
148
|
+
if (useProcessGroup && child.pid) process.kill(-child.pid, signal);
|
|
149
|
+
else child.kill(signal);
|
|
150
|
+
} catch {
|
|
151
|
+
// A process that already exited is handled by close below.
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const terminate = (reason) => {
|
|
155
|
+
if (terminationReason) return;
|
|
156
|
+
terminationReason = reason;
|
|
157
|
+
signalTree('SIGTERM');
|
|
158
|
+
hardKillTimer = setTimeout(() => signalTree('SIGKILL'), 500);
|
|
159
|
+
hardKillTimer.unref?.();
|
|
160
|
+
};
|
|
161
|
+
const append = (chunks, chunk, currentBytes) => {
|
|
162
|
+
const remaining = Math.max(0, OUTPUT_LIMIT_BYTES - currentBytes);
|
|
163
|
+
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
|
|
164
|
+
};
|
|
165
|
+
child.stdout.on('data', (chunk) => {
|
|
166
|
+
append(stdoutChunks, chunk, stdoutBytes);
|
|
167
|
+
stdoutBytes += chunk.length;
|
|
168
|
+
if (stdoutBytes > OUTPUT_LIMIT_BYTES) terminate('output-limit');
|
|
169
|
+
});
|
|
170
|
+
child.stderr.on('data', (chunk) => {
|
|
171
|
+
append(stderrChunks, chunk, stderrBytes);
|
|
172
|
+
stderrBytes += chunk.length;
|
|
173
|
+
if (stderrBytes > OUTPUT_LIMIT_BYTES) terminate('output-limit');
|
|
174
|
+
});
|
|
175
|
+
const timeoutTimer = setTimeout(() => terminate('timeout'), options.timeoutMs);
|
|
176
|
+
timeoutTimer.unref?.();
|
|
177
|
+
child.on('error', (error) => {
|
|
178
|
+
clearTimeout(timeoutTimer);
|
|
179
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
180
|
+
error.stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
181
|
+
error.stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
182
|
+
error.failureKind = terminationReason ?? 'spawn-error';
|
|
183
|
+
rejectPromise(error);
|
|
184
|
+
});
|
|
185
|
+
child.on('close', (code, signal) => {
|
|
186
|
+
clearTimeout(timeoutTimer);
|
|
187
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
188
|
+
const result = {
|
|
189
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
190
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
191
|
+
exitCode: code,
|
|
192
|
+
signal,
|
|
193
|
+
};
|
|
194
|
+
if (terminationReason || code !== 0) {
|
|
195
|
+
const error = new Error(terminationReason ?? `process exited with code ${code}`);
|
|
196
|
+
Object.assign(error, result, {
|
|
197
|
+
failureKind: terminationReason ?? 'non-zero-exit',
|
|
198
|
+
killed: Boolean(terminationReason),
|
|
199
|
+
});
|
|
200
|
+
rejectPromise(error);
|
|
201
|
+
} else {
|
|
202
|
+
resolvePromise(result);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function appendFailureEvidence({
|
|
209
|
+
evidence,
|
|
210
|
+
gate,
|
|
211
|
+
gateDigest,
|
|
212
|
+
inputDigest,
|
|
213
|
+
startedAt,
|
|
214
|
+
failureKind,
|
|
215
|
+
exitCode,
|
|
216
|
+
signal,
|
|
217
|
+
stdout,
|
|
218
|
+
stderr,
|
|
219
|
+
}) {
|
|
220
|
+
const stdoutInfo = outputSummary(stdout);
|
|
221
|
+
const stderrInfo = outputSummary(stderr);
|
|
222
|
+
await evidence?.append({
|
|
223
|
+
phase: gate.phase,
|
|
224
|
+
status: 'failed',
|
|
225
|
+
decision: 'fail-closed',
|
|
226
|
+
gateId: gate.id,
|
|
227
|
+
gateDigest,
|
|
228
|
+
inputDigest,
|
|
229
|
+
failureKind,
|
|
230
|
+
startedAt,
|
|
231
|
+
finishedAt: new Date().toISOString(),
|
|
232
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
233
|
+
signal: signal ?? null,
|
|
234
|
+
stdoutBytes: stdoutInfo.bytes,
|
|
235
|
+
stdoutSha256: stdoutInfo.sha256,
|
|
236
|
+
stderrBytes: stderrInfo.bytes,
|
|
237
|
+
stderrSha256: stderrInfo.sha256,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Execute one frozen gate definition against an isolated execution root. */
|
|
242
|
+
export async function runVerificationGate({
|
|
243
|
+
gate,
|
|
244
|
+
executionRoot,
|
|
245
|
+
evidence,
|
|
246
|
+
env = {},
|
|
247
|
+
fixedEnv = {},
|
|
248
|
+
inputDigest: expectedInputDigest,
|
|
249
|
+
}) {
|
|
250
|
+
validateGate(gate);
|
|
251
|
+
const cwd = await resolveSafeCwd(executionRoot, gate.cwd, gate);
|
|
252
|
+
const [executable, ...args] = gate.command;
|
|
253
|
+
const gateDigest = sha256Hex(canonicalJson(gate));
|
|
254
|
+
const observedInput = await computeFrozenSnapshot(executionRoot);
|
|
255
|
+
if (expectedInputDigest && observedInput.digest !== expectedInputDigest) {
|
|
256
|
+
throw gateError(gate, 'execution input changed before process start', {
|
|
257
|
+
expectedInputDigest,
|
|
258
|
+
observedInputDigest: observedInput.digest,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const inputDigest = observedInput.digest;
|
|
262
|
+
const startedAt = new Date().toISOString();
|
|
263
|
+
await evidence?.append({
|
|
264
|
+
phase: gate.phase,
|
|
265
|
+
status: 'started',
|
|
266
|
+
gateId: gate.id,
|
|
267
|
+
gateDigest,
|
|
268
|
+
inputDigest,
|
|
269
|
+
unitId: gate.scope.unit,
|
|
270
|
+
...(gate.scope.distribution ? { distribution: gate.scope.distribution } : {}),
|
|
271
|
+
executable,
|
|
272
|
+
args,
|
|
273
|
+
cwd: gate.cwd ?? '.',
|
|
274
|
+
timeoutMs: gate.timeoutMs,
|
|
275
|
+
envAllowlist: gate.envAllowlist,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let stdout = '';
|
|
279
|
+
let stderr = '';
|
|
280
|
+
try {
|
|
281
|
+
const result = await executeGateProcess(executable, args, {
|
|
282
|
+
cwd,
|
|
283
|
+
env: filteredEnv(gate.envAllowlist, env, fixedEnv),
|
|
284
|
+
timeoutMs: gate.timeoutMs,
|
|
285
|
+
});
|
|
286
|
+
stdout = result.stdout ?? '';
|
|
287
|
+
stderr = result.stderr ?? '';
|
|
288
|
+
} catch (error) {
|
|
289
|
+
stdout = error.stdout ?? '';
|
|
290
|
+
stderr = error.stderr ?? '';
|
|
291
|
+
const failureKind = error.failureKind ?? 'non-zero-exit';
|
|
292
|
+
await appendFailureEvidence({
|
|
293
|
+
evidence,
|
|
294
|
+
gate,
|
|
295
|
+
gateDigest,
|
|
296
|
+
inputDigest,
|
|
297
|
+
startedAt,
|
|
298
|
+
failureKind,
|
|
299
|
+
exitCode: error.exitCode,
|
|
300
|
+
signal: error.signal,
|
|
301
|
+
stdout,
|
|
302
|
+
stderr,
|
|
303
|
+
});
|
|
304
|
+
throw gateError(gate, `failed (${failureKind})`, { failureKind });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (gate.expectedJson !== undefined) {
|
|
308
|
+
let actual;
|
|
309
|
+
try {
|
|
310
|
+
actual = JSON.parse(stdout);
|
|
311
|
+
} catch {
|
|
312
|
+
await appendFailureEvidence({
|
|
313
|
+
evidence, gate, gateDigest, inputDigest, startedAt, failureKind: 'invalid-json', exitCode: 0, signal: null, stdout, stderr,
|
|
314
|
+
});
|
|
315
|
+
throw gateError(gate, 'returned invalid JSON', { failureKind: 'invalid-json' });
|
|
316
|
+
}
|
|
317
|
+
if (!matchesSubset(actual, gate.expectedJson)) {
|
|
318
|
+
await appendFailureEvidence({
|
|
319
|
+
evidence, gate, gateDigest, inputDigest, startedAt, failureKind: 'json-mismatch', exitCode: 0, signal: null, stdout, stderr,
|
|
320
|
+
});
|
|
321
|
+
throw gateError(gate, 'JSON output does not match expectedJson', { failureKind: 'json-mismatch' });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const stdoutInfo = outputSummary(stdout);
|
|
326
|
+
const stderrInfo = outputSummary(stderr);
|
|
327
|
+
const result = {
|
|
328
|
+
id: gate.id,
|
|
329
|
+
phase: gate.phase,
|
|
330
|
+
unitId: gate.scope.unit,
|
|
331
|
+
...(gate.scope.distribution ? { distribution: gate.scope.distribution } : {}),
|
|
332
|
+
gateDigest,
|
|
333
|
+
inputDigest,
|
|
334
|
+
status: 'passed',
|
|
335
|
+
startedAt,
|
|
336
|
+
finishedAt: new Date().toISOString(),
|
|
337
|
+
exitCode: 0,
|
|
338
|
+
stdoutBytes: stdoutInfo.bytes,
|
|
339
|
+
stdoutSha256: stdoutInfo.sha256,
|
|
340
|
+
stderrBytes: stderrInfo.bytes,
|
|
341
|
+
stderrSha256: stderrInfo.sha256,
|
|
342
|
+
};
|
|
343
|
+
await evidence?.append({ ...result, phase: gate.phase, status: 'completed', gateId: gate.id });
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function makeTreeWritable(root) {
|
|
348
|
+
const stat = await lstat(root);
|
|
349
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('gate copy root must be a real directory');
|
|
350
|
+
await chmod(root, stat.mode | 0o700);
|
|
351
|
+
for (const child of await readdir(root, { withFileTypes: true })) {
|
|
352
|
+
const absolute = join(root, child.name);
|
|
353
|
+
const childStat = await lstat(absolute);
|
|
354
|
+
if (childStat.isSymbolicLink()) throw new Error('gate copy must not contain symlinks');
|
|
355
|
+
if (childStat.isDirectory()) await makeTreeWritable(absolute);
|
|
356
|
+
else if (childStat.isFile() && childStat.nlink === 1) await chmod(absolute, childStat.mode | 0o600);
|
|
357
|
+
else throw new Error('gate copy must contain only single-link regular files');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Run snapshot gates on disposable writable copies and recheck the authority. */
|
|
362
|
+
export async function runSnapshotVerificationGates({
|
|
363
|
+
gates = [],
|
|
364
|
+
unitResults,
|
|
365
|
+
runDir,
|
|
366
|
+
evidence,
|
|
367
|
+
env = {},
|
|
368
|
+
copySnapshot = cp,
|
|
369
|
+
}) {
|
|
370
|
+
const snapshotGates = gates.filter((item) => item.phase === 'snapshot-verify');
|
|
371
|
+
if (snapshotGates.length === 0) return [];
|
|
372
|
+
const byUnit = new Map(unitResults.map((item) => [item.unit.id, item]));
|
|
373
|
+
const gateRoot = join(runDir, 'snapshot-gates');
|
|
374
|
+
await mkdir(gateRoot, { recursive: true });
|
|
375
|
+
const results = [];
|
|
376
|
+
|
|
377
|
+
for (const gate of snapshotGates) {
|
|
378
|
+
validateGate(gate);
|
|
379
|
+
const unitResult = byUnit.get(gate.scope.unit);
|
|
380
|
+
if (!unitResult) throw gateError(gate, 'references an unknown release unit');
|
|
381
|
+
const source = unitResult.manifest.outputDir;
|
|
382
|
+
const before = await computeFrozenSnapshot(source);
|
|
383
|
+
const copyDir = resolveUnitScopedPath(gateRoot, gate.id);
|
|
384
|
+
try {
|
|
385
|
+
await copySnapshot(source, copyDir, {
|
|
386
|
+
recursive: true,
|
|
387
|
+
dereference: false,
|
|
388
|
+
errorOnExist: true,
|
|
389
|
+
force: false,
|
|
390
|
+
});
|
|
391
|
+
const copied = await computeFrozenSnapshot(copyDir);
|
|
392
|
+
if (copied.digest !== before.digest) {
|
|
393
|
+
throw gateError(gate, 'copied execution input does not match the frozen snapshot authority', {
|
|
394
|
+
authorityDigest: before.digest,
|
|
395
|
+
copiedInputDigest: copied.digest,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
await makeTreeWritable(copyDir);
|
|
399
|
+
const executionInput = await computeFrozenSnapshot(copyDir);
|
|
400
|
+
if (executionInput.digest !== copied.digest) {
|
|
401
|
+
throw gateError(gate, 'execution input changed while making the disposable copy writable', {
|
|
402
|
+
copiedInputDigest: copied.digest,
|
|
403
|
+
executionInputDigest: executionInput.digest,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
results.push(await runVerificationGate({
|
|
407
|
+
gate,
|
|
408
|
+
executionRoot: copyDir,
|
|
409
|
+
evidence,
|
|
410
|
+
env,
|
|
411
|
+
fixedEnv: { HOME: copyDir },
|
|
412
|
+
inputDigest: executionInput.digest,
|
|
413
|
+
}));
|
|
414
|
+
const after = await computeFrozenSnapshot(source);
|
|
415
|
+
if (after.digest !== before.digest) {
|
|
416
|
+
throw gateError(gate, 'changed the frozen snapshot authority', {
|
|
417
|
+
beforeDigest: before.digest,
|
|
418
|
+
afterDigest: after.digest,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
await rm(copyDir, { recursive: true, force: true }).catch(() => {});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return results;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function selectConsumerVerificationGates(plan, unitId, distribution) {
|
|
429
|
+
return (plan.verificationGates ?? []).filter((gate) => (
|
|
430
|
+
gate.phase === 'consumer-verify' &&
|
|
431
|
+
gate.scope?.unit === unitId &&
|
|
432
|
+
gate.scope?.distribution === distribution
|
|
433
|
+
));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Run all exact unit/distribution consumer gates from the frozen plan. */
|
|
437
|
+
export async function runConsumerVerificationGates({
|
|
438
|
+
plan,
|
|
439
|
+
unitId,
|
|
440
|
+
distribution,
|
|
441
|
+
executionRoot,
|
|
442
|
+
evidence,
|
|
443
|
+
env = {},
|
|
444
|
+
fixedEnv = {},
|
|
445
|
+
}) {
|
|
446
|
+
const results = [];
|
|
447
|
+
for (const gate of selectConsumerVerificationGates(plan, unitId, distribution)) {
|
|
448
|
+
results.push(await runVerificationGate({ gate, executionRoot, evidence, env, fixedEnv }));
|
|
449
|
+
}
|
|
450
|
+
return results;
|
|
451
|
+
}
|
package/src/snapshot/frozen.mjs
CHANGED
|
@@ -169,14 +169,32 @@ export async function verifyFrozenDirectoryStructure(directory, label = 'frozen
|
|
|
169
169
|
await walk(directory);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
export async function verifyFrozenGitRepository({ root, gitObjectDir, commit, tree, exec = execFile }) {
|
|
172
|
+
export async function verifyFrozenGitRepository({ root, gitObjectDir, commit, tree, parentCommit, exec = execFile }) {
|
|
173
173
|
const gitDir = await resolveFrozenPath(root, gitObjectDir, 'frozen git object directory');
|
|
174
174
|
await verifyFrozenDirectoryStructure(gitDir, 'frozen git object directory');
|
|
175
175
|
const { stdout } = await exec('git', ['--git-dir', gitDir, 'rev-parse', `${commit}^{tree}`], { shell: false });
|
|
176
176
|
if (stdout.trim() !== tree) {
|
|
177
177
|
throw frozenError('frozen git object tree mismatch', { commit, expectedTree: tree, observedTree: stdout.trim() });
|
|
178
178
|
}
|
|
179
|
-
|
|
179
|
+
if (parentCommit) {
|
|
180
|
+
if (!/^[a-f0-9]{40,64}$/.test(parentCommit)) {
|
|
181
|
+
throw frozenError('frozen Git parent must be a full hexadecimal object id');
|
|
182
|
+
}
|
|
183
|
+
const { stdout: parentsOut } = await exec(
|
|
184
|
+
'git',
|
|
185
|
+
['--git-dir', gitDir, 'rev-list', '--parents', '-n', '1', commit],
|
|
186
|
+
{ shell: false },
|
|
187
|
+
);
|
|
188
|
+
const [observedCommit, ...parents] = parentsOut.trim().split(/\s+/);
|
|
189
|
+
if (observedCommit !== commit || parents.length !== 1 || parents[0] !== parentCommit) {
|
|
190
|
+
throw frozenError('frozen Git commit parent mismatch', {
|
|
191
|
+
commit,
|
|
192
|
+
expectedParent: parentCommit,
|
|
193
|
+
observedParents: parents,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { gitDir, commit, tree, ...(parentCommit ? { parentCommit } : {}) };
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
async function verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expectedSnapshotDigest, exec }) {
|
|
@@ -224,10 +242,58 @@ async function verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expect
|
|
|
224
242
|
}
|
|
225
243
|
}
|
|
226
244
|
|
|
227
|
-
|
|
245
|
+
function publicGitRemote({ repo, githubHost = 'github.com' }) {
|
|
246
|
+
if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
|
|
247
|
+
throw frozenError('Git parent repo must use owner/name format');
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
typeof githubHost !== 'string' || !/^[A-Za-z0-9.-]+$/.test(githubHost) ||
|
|
251
|
+
githubHost.startsWith('.') || githubHost.endsWith('.') || githubHost.includes('..')
|
|
252
|
+
) {
|
|
253
|
+
throw frozenError('Git parent githubHost must be a valid hostname');
|
|
254
|
+
}
|
|
255
|
+
return `https://${githubHost}/${repo}.git`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function buildFrozenGitRepository({
|
|
259
|
+
snapshotDir,
|
|
260
|
+
repositoryDir,
|
|
261
|
+
version,
|
|
262
|
+
expectedSnapshotDigest,
|
|
263
|
+
parent,
|
|
264
|
+
exec = execFile,
|
|
265
|
+
}) {
|
|
228
266
|
if (!expectedSnapshotDigest) throw frozenError('Git object build requires the sealed snapshot digest');
|
|
229
267
|
await mkdir(repositoryDir, { recursive: true });
|
|
230
268
|
await exec('git', ['init', '--bare', repositoryDir], { shell: false });
|
|
269
|
+
let parentCommit = null;
|
|
270
|
+
if (parent) {
|
|
271
|
+
if (!parent.ref || typeof parent.ref !== 'string') {
|
|
272
|
+
throw frozenError('Git parent ref must be a non-empty string');
|
|
273
|
+
}
|
|
274
|
+
if (!parent.commit || !/^[a-f0-9]{40,64}$/.test(parent.commit)) {
|
|
275
|
+
throw frozenError('Git parent commit must be a full hexadecimal object id');
|
|
276
|
+
}
|
|
277
|
+
const remoteUrl = publicGitRemote(parent);
|
|
278
|
+
await exec(
|
|
279
|
+
'git',
|
|
280
|
+
['--git-dir', repositoryDir, 'fetch', '--no-tags', remoteUrl, parent.ref],
|
|
281
|
+
{ shell: false },
|
|
282
|
+
);
|
|
283
|
+
const { stdout: fetchedOut } = await exec(
|
|
284
|
+
'git',
|
|
285
|
+
['--git-dir', repositoryDir, 'rev-parse', 'FETCH_HEAD^{commit}'],
|
|
286
|
+
{ shell: false },
|
|
287
|
+
);
|
|
288
|
+
if (fetchedOut.trim() !== parent.commit) {
|
|
289
|
+
throw frozenError('fetched Git parent does not match the observed public baseline', {
|
|
290
|
+
expectedCommit: parent.commit,
|
|
291
|
+
observedCommit: fetchedOut.trim(),
|
|
292
|
+
ref: parent.ref,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
parentCommit = parent.commit;
|
|
296
|
+
}
|
|
231
297
|
const indexPath = join(repositoryDir, 'release-index');
|
|
232
298
|
const env = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
233
299
|
await exec('git', [
|
|
@@ -249,14 +315,32 @@ export async function buildFrozenGitRepository({ snapshotDir, repositoryDir, ver
|
|
|
249
315
|
GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
|
|
250
316
|
GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
|
|
251
317
|
};
|
|
318
|
+
const commitArgs = ['--git-dir', repositoryDir, 'commit-tree', tree];
|
|
319
|
+
if (parentCommit) commitArgs.push('-p', parentCommit);
|
|
320
|
+
commitArgs.push('-m', `Release ${version}`);
|
|
252
321
|
const { stdout: commitOut } = await exec(
|
|
253
322
|
'git',
|
|
254
|
-
|
|
323
|
+
commitArgs,
|
|
255
324
|
{ env: commitEnv, shell: false },
|
|
256
325
|
);
|
|
257
326
|
const commit = commitOut.trim();
|
|
258
327
|
await verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expectedSnapshotDigest, exec });
|
|
259
|
-
|
|
328
|
+
if (parentCommit) {
|
|
329
|
+
const { stdout: parentsOut } = await exec(
|
|
330
|
+
'git',
|
|
331
|
+
['--git-dir', repositoryDir, 'rev-list', '--parents', '-n', '1', commit],
|
|
332
|
+
{ shell: false },
|
|
333
|
+
);
|
|
334
|
+
const [observedCommit, ...parents] = parentsOut.trim().split(/\s+/);
|
|
335
|
+
if (observedCommit !== commit || parents.length !== 1 || parents[0] !== parentCommit) {
|
|
336
|
+
throw frozenError('derived Git commit does not have the exact planned parent', {
|
|
337
|
+
commit,
|
|
338
|
+
parentCommit,
|
|
339
|
+
observedParents: parents,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { tree, commit, ...(parentCommit ? { parentCommit } : {}) };
|
|
260
344
|
}
|
|
261
345
|
|
|
262
346
|
function contentEntries(entries) {
|