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.
Files changed (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +60 -0
  5. package/INSTALL.md +179 -5
  6. package/INSTALL.zh-CN.md +320 -0
  7. package/README.md +347 -67
  8. package/README.zh-CN.md +318 -59
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/skills/release-help/SKILL.md +7 -4
  12. package/adapters/claude/skills/release-prepare/SKILL.md +11 -1
  13. package/adapters/claude/skills/release-publish/SKILL.md +6 -3
  14. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  15. package/adapters/claude/skills/release-setup/SKILL.md +111 -0
  16. package/adapters/claude/skills/release-verify/SKILL.md +5 -2
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/skills/release-help/SKILL.md +7 -4
  19. package/adapters/codex/skills/release-prepare/SKILL.md +11 -1
  20. package/adapters/codex/skills/release-publish/SKILL.md +6 -3
  21. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/skills/release-setup/SKILL.md +111 -0
  23. package/adapters/codex/skills/release-verify/SKILL.md +5 -2
  24. package/bin/release-skill.mjs +65 -9
  25. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  26. package/native/safe-write/prebuilds.json +22 -2
  27. package/native/safe-write/src/safe_write.cc +11 -2
  28. package/package.json +3 -1
  29. package/references/02-project-config.md +54 -3
  30. package/references/05-evidence-and-errors.md +6 -2
  31. package/schemas/release-plan.schema.json +550 -65
  32. package/schemas/release-project.schema.json +398 -29
  33. package/schemas/release-run.schema.json +165 -18
  34. package/skills/release-help/SKILL.md +7 -4
  35. package/skills/release-prepare/SKILL.md +11 -1
  36. package/skills/release-publish/SKILL.md +6 -3
  37. package/skills/release-reconcile/SKILL.md +1 -1
  38. package/skills/release-setup/SKILL.md +111 -0
  39. package/skills/release-verify/SKILL.md +5 -2
  40. package/skills-src/release-help/SKILL.md +7 -4
  41. package/skills-src/release-prepare/SKILL.md +11 -1
  42. package/skills-src/release-publish/SKILL.md +6 -3
  43. package/skills-src/release-reconcile/SKILL.md +1 -1
  44. package/skills-src/release-setup/SKILL.md +111 -0
  45. package/skills-src/release-verify/SKILL.md +5 -2
  46. package/src/adapters/contract.mjs +3 -0
  47. package/src/adapters/git-github.mjs +84 -2
  48. package/src/adapters/plugin-marketplace.mjs +65 -21
  49. package/src/adapters/push-snapshot.mjs +84 -17
  50. package/src/commands/prepare.mjs +223 -20
  51. package/src/commands/publish.mjs +45 -0
  52. package/src/commands/reconcile.mjs +152 -0
  53. package/src/commands/setup.mjs +886 -0
  54. package/src/commands/verify.mjs +122 -26
  55. package/src/core/config.mjs +34 -0
  56. package/src/core/errors.mjs +4 -0
  57. package/src/core/plan.mjs +123 -0
  58. package/src/core/previous-public-baseline.mjs +21 -1
  59. package/src/core/verification-gates.mjs +451 -0
  60. 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
+ }
@@ -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
- return { gitDir, commit, tree };
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
- export async function buildFrozenGitRepository({ snapshotDir, repositoryDir, version, expectedSnapshotDigest, exec = execFile }) {
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
- ['--git-dir', repositoryDir, 'commit-tree', tree, '-m', `Release ${version}`],
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
- return { tree, commit };
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) {