release-skill 0.1.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.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Safe snapshot export: build a public snapshot from a release unit.
3
+ *
4
+ * Provides a backward-compatible `buildSnapshot` adapter that accepts
5
+ * both `{sourceRoot,...}` (new) and `{root,...}` (legacy) calling
6
+ * conventions. Old callers pass `root` to mean repository root.
7
+ *
8
+ * All snapshot building goes through the explicit publicFiles mapper —
9
+ * no implicit git or package.json collection.
10
+ *
11
+ * Uses an explicit whitelist of allowed parameters. Any unknown option
12
+ * (including legacy `generatedFiles`, `forbiddenPaths`, `files`,
13
+ * `includePatterns`) is rejected with CONFIG_INVALID — fail closed.
14
+ *
15
+ * @module snapshot/export
16
+ */
17
+
18
+ import { buildPublicStaging } from './public-map.mjs';
19
+ import { ReleaseError, CONFIG_INVALID } from '../core/errors.mjs';
20
+
21
+ /** Explicit whitelist of allowed parameters for buildSnapshot. */
22
+ const ALLOWED_PARAMS = new Set([
23
+ 'sourceRoot',
24
+ 'root',
25
+ 'unit',
26
+ 'outputDir',
27
+ ]);
28
+
29
+ /**
30
+ * Backward-compatible buildSnapshot adapter.
31
+ *
32
+ * Accepts `sourceRoot` (preferred) or `root` (legacy alias).
33
+ * Uses an explicit whitelist — any unknown option is rejected with
34
+ * CONFIG_INVALID to prevent silent data loss.
35
+ *
36
+ * Internal test hooks (_afterCopy, _beforeOpen, _afterSourceRead,
37
+ * _afterDestRead, _fsOps) are NOT accepted by the public adapter.
38
+ * Tests must call buildPublicStaging directly for hook injection.
39
+ *
40
+ * @param {object} options
41
+ * @param {string} [options.sourceRoot] - Absolute source root.
42
+ * @param {string} [options.root] - Legacy alias for sourceRoot.
43
+ * @param {object} options.unit - Release unit configuration.
44
+ * @param {string} [options.outputDir] - Output directory for staged files.
45
+ * @returns {Promise<SnapshotManifest>}
46
+ */
47
+ export async function buildSnapshot(options = {}) {
48
+ // Reject null/undefined options — CONFIG_INVALID, not TypeError
49
+ if (options === null || options === undefined) {
50
+ throw new ReleaseError(
51
+ CONFIG_INVALID,
52
+ 'buildSnapshot options must be a non-null object',
53
+ { options },
54
+ );
55
+ }
56
+
57
+ // Reject any unknown parameters — whitelist, not blacklist.
58
+ // Hooks are intentionally excluded from the public API.
59
+ const unknownKeys = Object.keys(options).filter((k) => !ALLOWED_PARAMS.has(k));
60
+ if (unknownKeys.length > 0) {
61
+ throw new ReleaseError(
62
+ CONFIG_INVALID,
63
+ `unknown parameter(s) not allowed in buildSnapshot: ${unknownKeys.join(', ')}`,
64
+ { unknownKeys },
65
+ );
66
+ }
67
+
68
+ const {
69
+ sourceRoot,
70
+ root: legacyRoot,
71
+ unit,
72
+ outputDir,
73
+ } = options;
74
+
75
+ // Reject simultaneous sourceRoot and root — no silent precedence.
76
+ if (sourceRoot !== undefined && legacyRoot !== undefined) {
77
+ throw new ReleaseError(
78
+ CONFIG_INVALID,
79
+ 'buildSnapshot accepts either sourceRoot or root, not both',
80
+ { sourceRoot, root: legacyRoot },
81
+ );
82
+ }
83
+
84
+ const effectiveSourceRoot = sourceRoot ?? legacyRoot;
85
+
86
+ if (!effectiveSourceRoot) {
87
+ throw new ReleaseError(
88
+ CONFIG_INVALID,
89
+ 'buildSnapshot requires either sourceRoot or root',
90
+ { sourceRoot, root: legacyRoot },
91
+ );
92
+ }
93
+
94
+ return buildPublicStaging({
95
+ sourceRoot: effectiveSourceRoot,
96
+ unit,
97
+ outputDir,
98
+ });
99
+ }
@@ -0,0 +1,401 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile as execFileCb, spawn } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import {
5
+ chmod,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ open,
10
+ readdir,
11
+ realpath,
12
+ rm,
13
+ unlink,
14
+ } from 'node:fs/promises';
15
+ import { constants as fsConstants } from 'node:fs';
16
+ import { isAbsolute, join, relative, resolve } from 'node:path';
17
+
18
+ import { ReleaseError, GATE_FAILED } from '../core/errors.mjs';
19
+
20
+ const execFile = promisify(execFileCb);
21
+
22
+ function frozenError(message, details = {}) {
23
+ return new ReleaseError(GATE_FAILED, message, details);
24
+ }
25
+
26
+ function isInside(parent, candidate) {
27
+ const rel = relative(parent, candidate);
28
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`));
29
+ }
30
+
31
+ export async function resolveFrozenPath(root, relativePath, label = 'frozen path') {
32
+ if (!relativePath || typeof relativePath !== 'string' || isAbsolute(relativePath)) {
33
+ throw frozenError(`${label} must be a non-empty project-relative path`);
34
+ }
35
+ const rootReal = await realpath(root);
36
+ const lexical = resolve(rootReal, relativePath);
37
+ if (!isInside(rootReal, lexical)) {
38
+ throw frozenError(`${label} escapes project root`, { relativePath });
39
+ }
40
+ const lexicalStat = await lstat(lexical).catch((err) => {
41
+ throw frozenError(`${label} is missing`, { relativePath, cause: err.code });
42
+ });
43
+ if (lexicalStat.isSymbolicLink()) {
44
+ throw frozenError(`${label} must not be a symlink`, { relativePath });
45
+ }
46
+ const physical = await realpath(lexical).catch((err) => {
47
+ throw frozenError(`${label} is missing`, { relativePath, cause: err.code });
48
+ });
49
+ if (!isInside(rootReal, physical)) {
50
+ throw frozenError(`${label} resolves outside project root`, { relativePath });
51
+ }
52
+ return physical;
53
+ }
54
+
55
+ async function readStableRegularFile(filePath, displayPath) {
56
+ const before = await lstat(filePath);
57
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
58
+ throw frozenError(`frozen snapshot entry is not a single-link regular file: ${displayPath}`);
59
+ }
60
+
61
+ const handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
62
+ try {
63
+ const opened = await handle.stat();
64
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
65
+ throw frozenError(`frozen snapshot entry changed before read: ${displayPath}`);
66
+ }
67
+ const bytes = await handle.readFile();
68
+ const after = await handle.stat();
69
+ if (
70
+ after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 ||
71
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
72
+ ) {
73
+ throw frozenError(`frozen snapshot entry changed during read: ${displayPath}`);
74
+ }
75
+ return { bytes, mode: opened.mode };
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Compute the canonical snapshot digest.
83
+ *
84
+ * `excludeRootEntries` is reserved for consumer-owned transport metadata
85
+ * that is not part of the published payload (currently Codex's root `.git`
86
+ * checkout metadata). Exclusions only apply to direct children of the root;
87
+ * all payload paths retain the normal fail-closed file checks.
88
+ */
89
+ export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries = [] } = {}) {
90
+ const root = await realpath(snapshotDir);
91
+ const rootStat = await lstat(root);
92
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
93
+ throw frozenError('frozen snapshot root must be a real directory');
94
+ }
95
+
96
+ const entries = [];
97
+ const excluded = new Set(excludeRootEntries);
98
+ async function walk(dir) {
99
+ const children = await readdir(dir, { withFileTypes: true });
100
+ children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
101
+ for (const child of children) {
102
+ if (dir === root && excluded.has(child.name)) continue;
103
+ const absolute = join(dir, child.name);
104
+ const rel = relative(root, absolute).split('\\').join('/');
105
+ const st = await lstat(absolute);
106
+ if (st.isSymbolicLink()) {
107
+ throw frozenError(`frozen snapshot contains symlink: ${rel}`);
108
+ }
109
+ if (st.isDirectory()) {
110
+ await walk(absolute);
111
+ continue;
112
+ }
113
+ const { bytes, mode } = await readStableRegularFile(absolute, rel);
114
+ entries.push({
115
+ path: rel,
116
+ type: 'file',
117
+ mode,
118
+ size: bytes.length,
119
+ contentDigest: createHash('sha256').update(bytes).digest('hex'),
120
+ });
121
+ }
122
+ }
123
+ await walk(root);
124
+ // Match buildPublicStaging's locale-independent, code-unit ordering so the
125
+ // digest computed at copy time can be re-derived from disk byte-for-byte.
126
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
127
+ const digest = createHash('sha256').update(JSON.stringify(entries)).digest('hex');
128
+ return { digest, entries };
129
+ }
130
+
131
+ export async function verifyFrozenSnapshot({ root, snapshotPath, expectedDigest }) {
132
+ const snapshotDir = await resolveFrozenPath(root, snapshotPath, 'frozen snapshot path');
133
+ const observed = await computeFrozenSnapshot(snapshotDir);
134
+ if (!expectedDigest || observed.digest !== expectedDigest) {
135
+ throw frozenError('frozen snapshot digest mismatch', {
136
+ expectedDigest,
137
+ observedDigest: observed.digest,
138
+ });
139
+ }
140
+ return { snapshotDir, ...observed };
141
+ }
142
+
143
+ export async function verifyFrozenFile({ root, filePath, expectedSha256, label = 'frozen file' }) {
144
+ const physical = await resolveFrozenPath(root, filePath, label);
145
+ const { bytes } = await readStableRegularFile(physical, filePath);
146
+ const observedSha256 = createHash('sha256').update(bytes).digest('hex');
147
+ if (!expectedSha256 || observedSha256 !== expectedSha256) {
148
+ throw frozenError(`${label} SHA-256 mismatch`, { expectedSha256, observedSha256 });
149
+ }
150
+ return { physical, observedSha256, size: bytes.length };
151
+ }
152
+
153
+ export async function verifyFrozenDirectoryStructure(directory, label = 'frozen directory') {
154
+ async function walk(current) {
155
+ const children = await readdir(current, { withFileTypes: true });
156
+ for (const child of children) {
157
+ const absolute = join(current, child.name);
158
+ const st = await lstat(absolute);
159
+ if (st.isSymbolicLink()) {
160
+ throw frozenError(`${label} contains a symlink`);
161
+ }
162
+ if (st.isDirectory()) {
163
+ await walk(absolute);
164
+ } else if (!st.isFile() || st.nlink !== 1) {
165
+ throw frozenError(`${label} contains an unsafe non-regular or hardlinked entry`);
166
+ }
167
+ }
168
+ }
169
+ await walk(directory);
170
+ }
171
+
172
+ export async function verifyFrozenGitRepository({ root, gitObjectDir, commit, tree, exec = execFile }) {
173
+ const gitDir = await resolveFrozenPath(root, gitObjectDir, 'frozen git object directory');
174
+ await verifyFrozenDirectoryStructure(gitDir, 'frozen git object directory');
175
+ const { stdout } = await exec('git', ['--git-dir', gitDir, 'rev-parse', `${commit}^{tree}`], { shell: false });
176
+ if (stdout.trim() !== tree) {
177
+ throw frozenError('frozen git object tree mismatch', { commit, expectedTree: tree, observedTree: stdout.trim() });
178
+ }
179
+ return { gitDir, commit, tree };
180
+ }
181
+
182
+ async function verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expectedSnapshotDigest, exec }) {
183
+ const snapshot = await computeFrozenSnapshot(snapshotDir);
184
+ if (snapshot.digest !== expectedSnapshotDigest) {
185
+ throw frozenError('frozen snapshot changed while deriving Git objects', {
186
+ expectedDigest: expectedSnapshotDigest,
187
+ observedDigest: snapshot.digest,
188
+ });
189
+ }
190
+ const { stdout: treeOut } = await exec(
191
+ 'git',
192
+ ['--git-dir', repositoryDir, 'ls-tree', '-rz', commit],
193
+ { shell: false, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 },
194
+ );
195
+ const treeEntries = Buffer.from(treeOut).toString('utf8').split('\0').filter(Boolean).map((record) => {
196
+ const separator = record.indexOf('\t');
197
+ const [mode, type] = record.slice(0, separator).split(' ');
198
+ return { path: record.slice(separator + 1), mode, type };
199
+ }).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
200
+ const names = treeEntries.map((entry) => entry.path);
201
+ const expectedNames = snapshot.entries.map((entry) => entry.path);
202
+ if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
203
+ throw frozenError('frozen Git tree paths do not match the sealed public snapshot');
204
+ }
205
+ for (const [index, entry] of snapshot.entries.entries()) {
206
+ const gitEntry = treeEntries[index];
207
+ const expectedMode = entry.mode & 0o111 ? '100755' : '100644';
208
+ if (gitEntry.type !== 'blob' || gitEntry.mode !== expectedMode) {
209
+ throw frozenError(`frozen Git tree mode does not match the sealed public snapshot: ${entry.path}`, {
210
+ expectedMode,
211
+ observedMode: gitEntry.mode,
212
+ observedType: gitEntry.type,
213
+ });
214
+ }
215
+ const { stdout } = await exec(
216
+ 'git',
217
+ ['--git-dir', repositoryDir, 'show', `${commit}:${entry.path}`],
218
+ { shell: false, encoding: 'buffer', maxBuffer: Math.max(64 * 1024 * 1024, entry.size + 1024) },
219
+ );
220
+ const digest = createHash('sha256').update(Buffer.from(stdout)).digest('hex');
221
+ if (digest !== entry.contentDigest) {
222
+ throw frozenError(`frozen Git tree bytes do not match the sealed public snapshot: ${entry.path}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ export async function buildFrozenGitRepository({ snapshotDir, repositoryDir, version, expectedSnapshotDigest, exec = execFile }) {
228
+ if (!expectedSnapshotDigest) throw frozenError('Git object build requires the sealed snapshot digest');
229
+ await mkdir(repositoryDir, { recursive: true });
230
+ await exec('git', ['init', '--bare', repositoryDir], { shell: false });
231
+ const indexPath = join(repositoryDir, 'release-index');
232
+ const env = { ...process.env, GIT_INDEX_FILE: indexPath };
233
+ await exec('git', [
234
+ '--git-dir', repositoryDir,
235
+ '--work-tree', snapshotDir,
236
+ 'add', '--all', '--force', '--', '.',
237
+ ], { cwd: snapshotDir, env, shell: false });
238
+ const { stdout: treeOut } = await exec('git', ['--git-dir', repositoryDir, 'write-tree'], {
239
+ env,
240
+ shell: false,
241
+ });
242
+ const tree = treeOut.trim();
243
+ const commitEnv = {
244
+ ...env,
245
+ GIT_AUTHOR_NAME: 'release-skill',
246
+ GIT_AUTHOR_EMAIL: 'release-skill@localhost',
247
+ GIT_COMMITTER_NAME: 'release-skill',
248
+ GIT_COMMITTER_EMAIL: 'release-skill@localhost',
249
+ GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
250
+ GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
251
+ };
252
+ const { stdout: commitOut } = await exec(
253
+ 'git',
254
+ ['--git-dir', repositoryDir, 'commit-tree', tree, '-m', `Release ${version}`],
255
+ { env: commitEnv, shell: false },
256
+ );
257
+ const commit = commitOut.trim();
258
+ await verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expectedSnapshotDigest, exec });
259
+ return { tree, commit };
260
+ }
261
+
262
+ function contentEntries(entries) {
263
+ return entries.map(({ path, size, contentDigest }) => ({ path, size, contentDigest }));
264
+ }
265
+
266
+ async function createDetachedReadHandle(bytes, directory) {
267
+ const tempDir = await mkdtemp(join(directory, '.tar-fd-'));
268
+ const tempPath = join(tempDir, 'package.tgz');
269
+ let handle;
270
+ try {
271
+ const writer = await open(tempPath, 'wx', 0o400);
272
+ try {
273
+ await writer.writeFile(bytes);
274
+ await writer.sync();
275
+ } finally {
276
+ await writer.close();
277
+ }
278
+ handle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
279
+ await unlink(tempPath);
280
+ await rm(tempDir, { recursive: true, force: true });
281
+ return handle;
282
+ } catch (err) {
283
+ await handle?.close().catch(() => {});
284
+ await rm(tempDir, { recursive: true, force: true });
285
+ throw err;
286
+ }
287
+ }
288
+
289
+ async function runTarFromHandle(handle, args) {
290
+ const fdPath = process.platform === 'linux' ? '/proc/self/fd/3' : '/dev/fd/3';
291
+ return new Promise((resolvePromise, rejectPromise) => {
292
+ const child = spawn('tar', args(fdPath), {
293
+ shell: false,
294
+ env: process.env,
295
+ stdio: ['ignore', 'pipe', 'pipe', handle.fd],
296
+ });
297
+ let stdout = '';
298
+ let stderr = '';
299
+ child.stdout.setEncoding('utf8');
300
+ child.stderr.setEncoding('utf8');
301
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
302
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
303
+ child.on('error', rejectPromise);
304
+ child.on('close', (code) => {
305
+ if (code === 0) resolvePromise({ stdout, stderr });
306
+ else rejectPromise(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
307
+ });
308
+ });
309
+ }
310
+
311
+ async function verifyNpmTarballContent({ snapshotDir, tarballBytes, tarballDir, expectedSnapshotDigest }) {
312
+ const listHandle = await createDetachedReadHandle(tarballBytes, tarballDir);
313
+ let listOut;
314
+ try {
315
+ ({ stdout: listOut } = await runTarFromHandle(listHandle, (fdPath) => ['-tzf', fdPath]));
316
+ } finally {
317
+ await listHandle.close();
318
+ }
319
+ const listed = listOut.split(/\r?\n/).filter(Boolean);
320
+ if (listed.length === 0 || listed.some((entry) => (
321
+ !entry.startsWith('package/') || entry.startsWith('/') || entry.includes('\\') ||
322
+ entry.split('/').some((segment) => segment === '..')
323
+ ))) {
324
+ throw frozenError('npm tarball contains an unsafe or unexpected path');
325
+ }
326
+
327
+ const verifyDir = await mkdtemp(join(tarballDir, '.verify-'));
328
+ try {
329
+ const extractHandle = await createDetachedReadHandle(tarballBytes, tarballDir);
330
+ try {
331
+ await runTarFromHandle(extractHandle, (fdPath) => ['-xzf', fdPath, '-C', verifyDir]);
332
+ } finally {
333
+ await extractHandle.close();
334
+ }
335
+ const original = await computeFrozenSnapshot(snapshotDir);
336
+ if (original.digest !== expectedSnapshotDigest) {
337
+ throw frozenError('frozen snapshot changed while deriving npm tarball', {
338
+ expectedDigest: expectedSnapshotDigest,
339
+ observedDigest: original.digest,
340
+ });
341
+ }
342
+ const packed = await computeFrozenSnapshot(join(verifyDir, 'package'));
343
+ if (JSON.stringify(contentEntries(packed.entries)) !== JSON.stringify(contentEntries(original.entries))) {
344
+ throw frozenError('npm tarball bytes do not match the sealed public snapshot');
345
+ }
346
+ } finally {
347
+ await rm(verifyDir, { recursive: true, force: true });
348
+ }
349
+ }
350
+
351
+ export async function buildFrozenNpmTarball({ snapshotDir, tarballDir, expectedSnapshotDigest, exec = execFile }) {
352
+ await mkdir(tarballDir, { recursive: true });
353
+ const { stdout } = await exec(
354
+ 'npm',
355
+ ['pack', snapshotDir, '--pack-destination', tarballDir, '--json', '--ignore-scripts'],
356
+ { shell: false, encoding: 'utf8', timeout: 120_000 },
357
+ );
358
+ let parsed;
359
+ try {
360
+ parsed = JSON.parse(stdout);
361
+ } catch {
362
+ throw frozenError('npm pack returned invalid JSON');
363
+ }
364
+ const info = Array.isArray(parsed) ? parsed[0] : parsed;
365
+ if (!info?.filename || !info?.integrity) {
366
+ throw frozenError('npm pack did not return filename and integrity');
367
+ }
368
+ const tarballPath = join(tarballDir, info.filename);
369
+ const { bytes } = await readStableRegularFile(tarballPath, info.filename);
370
+ if (!expectedSnapshotDigest) throw frozenError('npm tarball build requires the sealed snapshot digest');
371
+ await verifyNpmTarballContent({
372
+ snapshotDir,
373
+ tarballBytes: bytes,
374
+ tarballDir,
375
+ expectedSnapshotDigest,
376
+ });
377
+ return {
378
+ tarballPath,
379
+ integrity: info.integrity,
380
+ sha256: createHash('sha256').update(bytes).digest('hex'),
381
+ size: bytes.length,
382
+ };
383
+ }
384
+
385
+ export async function sealFrozenSnapshot(snapshotDir) {
386
+ async function walk(dir) {
387
+ const children = await readdir(dir, { withFileTypes: true });
388
+ for (const child of children) {
389
+ const absolute = join(dir, child.name);
390
+ const st = await lstat(absolute);
391
+ if (st.isDirectory()) {
392
+ await walk(absolute);
393
+ await chmod(absolute, 0o555);
394
+ } else if (st.isFile()) {
395
+ await chmod(absolute, st.mode & 0o111 ? 0o555 : 0o444);
396
+ }
397
+ }
398
+ }
399
+ await walk(snapshotDir);
400
+ await chmod(snapshotDir, 0o555);
401
+ }