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,637 @@
1
+ /**
2
+ * Git authority: repository identity, commit-tree reading, merge-base search,
3
+ * and attribute safety gate.
4
+ *
5
+ * All git interactions use `execFile('git', args, { cwd, shell: false })` —
6
+ * no shell strings, no network writes.
7
+ *
8
+ * @module artifacts/git-authority
9
+ */
10
+
11
+ import { promisify } from 'node:util';
12
+ import { execFile } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+
15
+ import {
16
+ ReleaseError,
17
+ BASE_UNAVAILABLE,
18
+ LOCK_MIGRATION_REQUIRED,
19
+ PATH_UNSAFE,
20
+ } from '../core/errors.mjs';
21
+ import { digestEntryManifest } from './entry.mjs';
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Internal helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Run a git command and return trimmed stdout.
31
+ *
32
+ * @param {string} cwd
33
+ * @param {string[]} args
34
+ * @returns {Promise<string>}
35
+ */
36
+ async function git(cwd, ...args) {
37
+ const { stdout } = await execFileAsync('git', args, { cwd, shell: false });
38
+ return stdout.trim();
39
+ }
40
+
41
+ /**
42
+ * Compute SHA-256 hex of a string.
43
+ *
44
+ * @param {string} data
45
+ * @returns {string}
46
+ */
47
+ function sha256Hex(data) {
48
+ return createHash('sha256').update(data).digest('hex');
49
+ }
50
+
51
+ /**
52
+ * Check git attributes for dangerous settings (custom clean filter,
53
+ * working-tree-encoding).
54
+ *
55
+ * When `commitRef` is provided, reads `.gitattributes` from the commit tree
56
+ * and checks attributes using a temporary environment with `GIT_ATTR_SOURCE`
57
+ * or by using `git check-attr` with the attributes sourced from the commit.
58
+ * This ensures the attributes gate reflects the commit's own attributes,
59
+ * not the working tree's.
60
+ *
61
+ * For git versions that support `--source=<commit>` (git 2.40+), that flag
62
+ * is used directly. Otherwise, falls back to reading `.gitattributes` from
63
+ * the commit tree and parsing them manually.
64
+ *
65
+ * @param {string} cwd - Repository root.
66
+ * @param {string[]} paths - Paths to check.
67
+ * @param {string|null} commitRef - Commit to source attributes from.
68
+ * @throws {ReleaseError} PATH_UNSAFE on dangerous attributes.
69
+ */
70
+ async function checkUnsafeAttributes(cwd, paths, commitRef = null) {
71
+ if (paths.length === 0) return;
72
+
73
+ // When commitRef is provided, check attributes from the commit tree only.
74
+ // This ensures the gate reflects the commit's own attributes, not the
75
+ // working tree's (which may have changed since the commit).
76
+ if (commitRef) {
77
+ // Try --source=<commit> first (git 2.40+)
78
+ try {
79
+ const args = ['check-attr', '-z', `--source=${commitRef}`, 'filter', 'working-tree-encoding', ...paths];
80
+ const { stdout } = await execFileAsync('git', args, { cwd, shell: false });
81
+ return parseAndCheckAttributes(stdout);
82
+ } catch (err) {
83
+ // --source not supported — fall through to commit-tree-based check
84
+ if (!err.message?.includes('unknown option')) throw err;
85
+ }
86
+
87
+ // Fallback: parse .gitattributes from the commit tree directly
88
+ return checkCommitAttributesFromTree(cwd, commitRef, paths);
89
+ }
90
+
91
+ // No commitRef — use working tree attributes
92
+ const args = ['check-attr', '-z', 'filter', 'working-tree-encoding', ...paths];
93
+ const { stdout } = await execFileAsync('git', args, { cwd, shell: false });
94
+ parseAndCheckAttributes(stdout);
95
+ }
96
+
97
+ /**
98
+ * Parse NUL-delimited git check-attr output and reject dangerous values.
99
+ *
100
+ * @param {string} stdout - NUL-delimited output.
101
+ * @throws {ReleaseError} PATH_UNSAFE on dangerous attributes.
102
+ */
103
+ function parseAndCheckAttributes(stdout) {
104
+ const fields = stdout.split('\0').filter((s) => s.length > 0);
105
+ if (fields.length === 0) return;
106
+
107
+ for (let i = 0; i + 2 < fields.length; i += 3) {
108
+ const attr = fields[i + 1];
109
+ const value = fields[i + 2];
110
+ if (attr === 'filter' || attr === 'working-tree-encoding') {
111
+ if (value !== 'unspecified' && value !== 'unset') {
112
+ throw new ReleaseError(
113
+ PATH_UNSAFE,
114
+ `dangerous git attribute "${attr}" = "${value}"`,
115
+ { path: fields[i], attribute: attr, value },
116
+ );
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check the commit's .gitattributes tree for dangerous settings.
124
+ *
125
+ * Reads the `.gitattributes` blob from the commit tree and parses it
126
+ * to detect `filter=` or `working-tree-encoding=` attributes that the
127
+ * working tree might not have.
128
+ *
129
+ * @param {string} cwd - Repository root.
130
+ * @param {string} commitRef - Commit to check.
131
+ * @param {string[]} paths - Paths to check against.
132
+ * @throws {ReleaseError} PATH_UNSAFE on dangerous attributes.
133
+ */
134
+ async function checkCommitAttributesFromTree(cwd, commitRef, paths) {
135
+ let attrContent;
136
+ try {
137
+ const { stdout } = await execFileAsync(
138
+ 'git',
139
+ ['cat-file', 'blob', `${commitRef}:.gitattributes`],
140
+ { cwd, shell: false, encoding: 'utf8' },
141
+ );
142
+ attrContent = stdout;
143
+ } catch {
144
+ // No .gitattributes in the commit — safe
145
+ return;
146
+ }
147
+
148
+ // Simple glob-based matching for the most common patterns
149
+ // We only need to detect dangerous settings, not full attribute resolution
150
+ const lines = attrContent.split('\n').filter((l) => l.trim() && !l.startsWith('#'));
151
+
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ // Check for filter= or working-tree-encoding= in any attribute spec
155
+ if (/\bfilter=/.test(trimmed) && !/\bfilter=\s*unspecified/.test(trimmed)) {
156
+ // Check if any of our paths match the pattern
157
+ const pattern = trimmed.split(/\s+/)[0];
158
+ for (const p of paths) {
159
+ if (matchesGitattributesPattern(pattern, p)) {
160
+ throw new ReleaseError(
161
+ PATH_UNSAFE,
162
+ `dangerous git attribute "filter" in commit ${commitRef}'s .gitattributes`,
163
+ { path: p, attribute: 'filter', commit: commitRef },
164
+ );
165
+ }
166
+ }
167
+ }
168
+ if (/\bworking-tree-encoding=/.test(trimmed) && !/\bworking-tree-encoding=\s*unspecified/.test(trimmed)) {
169
+ const pattern = trimmed.split(/\s+/)[0];
170
+ for (const p of paths) {
171
+ if (matchesGitattributesPattern(pattern, p)) {
172
+ throw new ReleaseError(
173
+ PATH_UNSAFE,
174
+ `dangerous git attribute "working-tree-encoding" in commit ${commitRef}'s .gitattributes`,
175
+ { path: p, attribute: 'working-tree-encoding', commit: commitRef },
176
+ );
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Simple glob pattern matching for .gitattributes patterns.
185
+ * Supports `*` wildcard and literal matches.
186
+ *
187
+ * @param {string} pattern - Gitattributes pattern (e.g. `*.md`, `file.txt`).
188
+ * @param {string} path - Path to match against.
189
+ * @returns {boolean}
190
+ */
191
+ function matchesGitattributesPattern(pattern, path) {
192
+ // Convert gitattributes glob to regex
193
+ const regex = new RegExp(
194
+ '^' + pattern
195
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
196
+ .replace(/\*/g, '.*')
197
+ .replace(/\?/g, '.') + '$',
198
+ );
199
+ // Match against basename or full path
200
+ return regex.test(path) || regex.test(path.split('/').pop());
201
+ }
202
+
203
+ /**
204
+ * Parse NUL-separated `git ls-tree -rz` output into entry objects.
205
+ *
206
+ * Each line has the format: `<mode> <type> <oid>\t<path>`
207
+ *
208
+ * @param {string} raw - Raw NUL-delimited ls-tree output.
209
+ * @returns {Array<{ path: string, gitOid: string, mode: string, type: string }>}
210
+ */
211
+ function parseLsTree(raw) {
212
+ if (!raw) return [];
213
+ const entries = [];
214
+ const chunks = raw.split('\0').filter((s) => s.length > 0);
215
+ for (const chunk of chunks) {
216
+ const tabIdx = chunk.indexOf('\t');
217
+ if (tabIdx < 0) continue;
218
+ const meta = chunk.slice(0, tabIdx);
219
+ const path = chunk.slice(tabIdx + 1);
220
+ const parts = meta.split(' ');
221
+ if (parts.length < 3) continue;
222
+ entries.push({ mode: parts[0], type: parts[1], gitOid: parts[2], path });
223
+ }
224
+ return entries;
225
+ }
226
+
227
+ /**
228
+ * Get the blob content from the git object store.
229
+ *
230
+ * @param {string} cwd - Repository root.
231
+ * @param {string} oid - Blob object ID.
232
+ * @returns {Promise<Buffer>}
233
+ */
234
+ async function gitCatFileBlob(cwd, oid) {
235
+ const { stdout } = await execFileAsync(
236
+ 'git',
237
+ ['cat-file', 'blob', oid],
238
+ { cwd, shell: false, encoding: 'buffer', maxBuffer: 50 * 1024 * 1024 },
239
+ );
240
+ return stdout;
241
+ }
242
+
243
+ /**
244
+ * List first-parent commit chain from HEAD.
245
+ *
246
+ * @param {string} cwd
247
+ * @returns {Promise<string[]>} Commit hashes, HEAD first.
248
+ */
249
+ async function revListFirstParent(cwd) {
250
+ try {
251
+ const { stdout } = await execFileAsync(
252
+ 'git',
253
+ ['rev-list', '--first-parent', 'HEAD'],
254
+ { cwd, shell: false },
255
+ );
256
+ return stdout.split('\n').filter((s) => s.length > 0);
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Read a tree recursively via `git ls-tree -rz` and build a manifest.
264
+ *
265
+ * @param {string} cwd - Repository root.
266
+ * @param {string} treeRef - Tree-ish (commit, tree OID, etc.).
267
+ * @param {string} treePath - Path to the tree within the commit.
268
+ * @returns {Promise<{ type: 'tree', entries: object[], manifestDigest: string }>}
269
+ */
270
+ async function readTreeRecursive(cwd, treeRef, treePath) {
271
+ const { stdout: raw } = await execFileAsync(
272
+ 'git',
273
+ ['ls-tree', '-rz', `${treeRef}:${treePath}`],
274
+ { cwd, shell: false, maxBuffer: 50 * 1024 * 1024 },
275
+ );
276
+ const parsed = parseLsTree(raw);
277
+ const entries = [];
278
+ for (const pe of parsed) {
279
+ // Paths within the tree are relative to treePath
280
+ const fullPath = treePath ? `${treePath}/${pe.path}` : pe.path;
281
+ if (pe.type === 'blob') {
282
+ const content = await gitCatFileBlob(cwd, pe.gitOid);
283
+ entries.push(
284
+ Object.freeze({
285
+ path: fullPath,
286
+ type: 'blob',
287
+ mode: pe.mode,
288
+ gitOid: pe.gitOid,
289
+ sha256: sha256Hex(content),
290
+ size: content.length,
291
+ }),
292
+ );
293
+ } else if (pe.type === 'tree') {
294
+ // Recurse into subtree
295
+ const sub = await readTreeRecursive(cwd, treeRef, fullPath);
296
+ entries.push(...sub.entries);
297
+ }
298
+ }
299
+ // Sort for deterministic ordering
300
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
301
+ return {
302
+ type: 'tree',
303
+ entries,
304
+ manifestDigest: digestEntryManifest(entries),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Validate that a lock matches the designed authoritative shape.
310
+ *
311
+ * Old-format locks (missing `lockVersion` or `entries`) are rejected
312
+ * as LOCK_MIGRATION_REQUIRED.
313
+ *
314
+ * @param {object} lock
315
+ * @throws {ReleaseError} LOCK_MIGRATION_REQUIRED on old format.
316
+ */
317
+ function validateLockFormat(lock) {
318
+ if (!lock || typeof lock !== 'object') {
319
+ throw new ReleaseError(
320
+ BASE_UNAVAILABLE,
321
+ 'artifact lock is missing or invalid',
322
+ {},
323
+ );
324
+ }
325
+ // Old format detection: must have lockVersion and entries
326
+ if (lock.lockVersion !== 1 || !lock.entries || typeof lock.entries !== 'object') {
327
+ throw new ReleaseError(
328
+ LOCK_MIGRATION_REQUIRED,
329
+ 'lock uses old format (missing lockVersion or entries); migration required',
330
+ { field: 'lockFormat', reason: 'old artifactIds/manifestDigest format' },
331
+ );
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Compare lock entries against policy artifacts for path/ownership/driver changes.
337
+ *
338
+ * @param {object} lockEntries - Lock's `entries` object keyed by artifact ID.
339
+ * @param {Array} policyArtifacts - Policy artifact definitions.
340
+ * @throws {ReleaseError} LOCK_MIGRATION_REQUIRED on any change.
341
+ */
342
+ function validateLockPolicyConsistency(lockEntries, policyArtifacts) {
343
+ if (!Array.isArray(policyArtifacts) || policyArtifacts.length === 0) return;
344
+
345
+ for (const pa of policyArtifacts) {
346
+ const lockEntry = lockEntries[pa.id];
347
+ if (!lockEntry) continue; // New artifact in policy — not a change to existing
348
+
349
+ // Path change
350
+ if (lockEntry.path && pa.path && lockEntry.path !== pa.path) {
351
+ throw new ReleaseError(
352
+ LOCK_MIGRATION_REQUIRED,
353
+ `artifact "${pa.id}" path changed from "${lockEntry.path}" to "${pa.path}"`,
354
+ { field: 'path', artifactId: pa.id, lockPath: lockEntry.path, policyPath: pa.path },
355
+ );
356
+ }
357
+
358
+ // Ownership change
359
+ const lockOwnership = lockEntry.ownership ?? lockEntries[pa.id]?.ownership;
360
+ if (lockOwnership && pa.ownership && lockOwnership !== pa.ownership) {
361
+ throw new ReleaseError(
362
+ LOCK_MIGRATION_REQUIRED,
363
+ `artifact "${pa.id}" ownership changed from "${lockOwnership}" to "${pa.ownership}"`,
364
+ { field: 'ownership', artifactId: pa.id, lockOwnership, policyOwnership: pa.ownership },
365
+ );
366
+ }
367
+
368
+ // Merge driver change
369
+ const lockDriver = lockEntry.mergeDriver ?? lockEntries[pa.id]?.mergeDriver;
370
+ if (lockDriver && pa.mergeDriver && lockDriver !== pa.mergeDriver) {
371
+ throw new ReleaseError(
372
+ LOCK_MIGRATION_REQUIRED,
373
+ `artifact "${pa.id}" merge driver changed from "${lockDriver}" to "${pa.mergeDriver}"`,
374
+ { field: 'mergeDriver', artifactId: pa.id, lockDriver, policyDriver: pa.mergeDriver },
375
+ );
376
+ }
377
+ }
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Public API
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Read the repository identity: gitDir, commonDir, and remoteUrlHash.
386
+ *
387
+ * `remoteUrlHash` is `sha256:<hex>` of the origin remote URL, or
388
+ * `sha256:<64 zeros>` if no origin is configured.
389
+ *
390
+ * @param {string} root - Repository root (absolute).
391
+ * @returns {Promise<{ gitDir: string, commonDir: string, remoteUrlHash: string }>}
392
+ * @throws {ReleaseError} PATH_UNSAFE if not inside a git repository.
393
+ */
394
+ export async function readRepositoryIdentity(root) {
395
+ let gitDir;
396
+ try {
397
+ gitDir = await git(root, 'rev-parse', '--absolute-git-dir');
398
+ } catch {
399
+ throw new ReleaseError(PATH_UNSAFE, 'not inside a git repository', { root });
400
+ }
401
+
402
+ let commonDir;
403
+ try {
404
+ commonDir = await git(root, 'rev-parse', '--git-common-dir');
405
+ } catch {
406
+ commonDir = gitDir;
407
+ }
408
+
409
+ let remoteUrl = '';
410
+ try {
411
+ remoteUrl = await git(root, 'config', '--get', 'remote.origin.url');
412
+ } catch {
413
+ // No origin configured — use empty string.
414
+ }
415
+
416
+ const remoteUrlHash = remoteUrl
417
+ ? `sha256:${sha256Hex(remoteUrl)}`
418
+ : `sha256:${'0'.repeat(64)}`;
419
+
420
+ return Object.freeze({ gitDir, commonDir, remoteUrlHash });
421
+ }
422
+
423
+ /**
424
+ * Read artifact entries from a git commit tree for the given paths.
425
+ *
426
+ * Validates that no path has a custom `filter` or `working-tree-encoding`
427
+ * git attribute set **in the specified commit** (not the working tree).
428
+ * Uses `--source=<commit>` to read attributes from the commit tree.
429
+ *
430
+ * Supports three entry types:
431
+ * - **absent**: path not found in the commit tree (skipped).
432
+ * - **regular file**: blob entry with sha256 content digest.
433
+ * - **tree**: directory entry — recursed via `git ls-tree -rz` to build
434
+ * a complete manifest with deterministic digest.
435
+ *
436
+ * Rejects symlink mode `120000`, gitlink `160000` with PATH_UNSAFE.
437
+ *
438
+ * @param {object} options
439
+ * @param {string} options.root - Repository root (absolute).
440
+ * @param {string} options.commit - Commit hash or ref.
441
+ * @param {string[]} options.paths - Artifact paths to read.
442
+ * @returns {Promise<Map<string, object>>} Map of path → ArtifactEntry.
443
+ * @throws {ReleaseError} PATH_UNSAFE on dangerous git attributes or entry types.
444
+ */
445
+ export async function readCommitEntries({ root, commit, paths } = {}) {
446
+ if (paths.length === 0) return new Map();
447
+
448
+ // Check for dangerous git attributes against the COMMIT tree, not working tree
449
+ await checkUnsafeAttributes(root, paths, commit);
450
+
451
+ const result = new Map();
452
+
453
+ for (const path of paths) {
454
+ // Determine the object type at this path in the commit tree.
455
+ // `git ls-tree -rz <commit> <path>` returns blob/tree entries directly.
456
+ // For directories, it returns the contents (not the tree entry itself).
457
+ // We use `git cat-file -t <commit>:<path>` to detect the type first.
458
+ let objectType;
459
+ try {
460
+ objectType = await git(root, 'cat-file', '-t', `${commit}:${path}`);
461
+ } catch {
462
+ // Path does not exist in the commit tree — skip (absent)
463
+ continue;
464
+ }
465
+
466
+ if (objectType === 'tree') {
467
+ // Tree entry: recurse via git ls-tree to build complete manifest
468
+ const treeManifest = await readTreeRecursive(root, commit, path);
469
+
470
+ // Get the tree OID
471
+ const treeOid = await git(root, 'rev-parse', `${commit}:${path}`);
472
+
473
+ result.set(path, Object.freeze({
474
+ path,
475
+ type: 'tree',
476
+ mode: '040000',
477
+ gitOid: treeOid,
478
+ entries: Object.freeze(treeManifest.entries),
479
+ manifestDigest: treeManifest.manifestDigest,
480
+ }));
481
+ } else if (objectType === 'blob') {
482
+ // Get the blob entry metadata via ls-tree
483
+ const { stdout: raw } = await execFileAsync(
484
+ 'git',
485
+ ['ls-tree', '-rz', commit, path],
486
+ { cwd: root, shell: false, maxBuffer: 50 * 1024 * 1024 },
487
+ );
488
+ const treeEntries = parseLsTree(raw);
489
+ const te = treeEntries[0];
490
+ if (!te) continue;
491
+
492
+ // Reject unsafe entry types
493
+ if (te.mode === '120000') {
494
+ throw new ReleaseError(
495
+ PATH_UNSAFE,
496
+ `symlink mode 120000 rejected for "${path}"`,
497
+ { path, mode: te.mode },
498
+ );
499
+ }
500
+ if (te.mode === '160000') {
501
+ throw new ReleaseError(
502
+ PATH_UNSAFE,
503
+ `gitlink mode 160000 rejected for "${path}"`,
504
+ { path, mode: te.mode },
505
+ );
506
+ }
507
+
508
+ const content = await gitCatFileBlob(root, te.gitOid);
509
+ result.set(
510
+ path,
511
+ Object.freeze({
512
+ path,
513
+ type: 'blob',
514
+ mode: te.mode,
515
+ gitOid: te.gitOid,
516
+ sha256: sha256Hex(content),
517
+ size: content.length,
518
+ }),
519
+ );
520
+ }
521
+ // Other types (tag, commit) are skipped
522
+ }
523
+
524
+ return result;
525
+ }
526
+
527
+ /**
528
+ * Find the first ancestor commit in first-parent history whose artifact
529
+ * manifest digest matches the lock's accepted manifest.
530
+ *
531
+ * Validates:
532
+ * - Lock uses the new format (`lockVersion: 1` with `entries`); old format
533
+ * (`artifactIds/manifestDigest`) → LOCK_MIGRATION_REQUIRED.
534
+ * - Lock's `repositoryIdentity.remoteUrlHash` matches the current repository's
535
+ * remoteUrlHash (not absolute gitDir).
536
+ * - Lock's `policyDigest` matches the caller-provided policyDigest (if any).
537
+ * - Each lock entry's path/ownership/mergeDriver matches policy artifacts (if any).
538
+ * - Base search checks entries' path/type/mode/gitOid/sha256 against commit tree
539
+ * and compares `acceptedArtifactManifestDigest`.
540
+ *
541
+ * On missing ancestor: `BASE_UNAVAILABLE.details.nextAction.command` is a
542
+ * read-only fetch bound to the repository's remoteUrlHash.
543
+ *
544
+ * @param {object} options
545
+ * @param {string} options.root - Repository root (absolute).
546
+ * @param {object} options.lock - Artifact lock object (new format).
547
+ * @param {string} [options.policyDigest] - Current policy digest to compare against lock.
548
+ * @param {Array} [options.policyArtifacts] - Policy artifact definitions for change detection.
549
+ * @returns {Promise<string>} Commit hash of the matching ancestor.
550
+ * @throws {ReleaseError} LOCK_MIGRATION_REQUIRED on identity/policy/format mismatch.
551
+ * @throws {ReleaseError} BASE_UNAVAILABLE if no matching ancestor is found.
552
+ */
553
+ export async function findMergeBaseCommit({ root, lock, policyDigest, policyArtifacts } = {}) {
554
+ // Validate lock format (new authoritative shape)
555
+ validateLockFormat(lock);
556
+
557
+ // Validate lock has required identity fields
558
+ if (!lock.repositoryIdentity || !lock.repositoryIdentity.remoteUrlHash) {
559
+ throw new ReleaseError(
560
+ LOCK_MIGRATION_REQUIRED,
561
+ 'lock missing repositoryIdentity.remoteUrlHash',
562
+ { field: 'repositoryIdentity' },
563
+ );
564
+ }
565
+
566
+ if (!lock.acceptedArtifactManifestDigest) {
567
+ throw new ReleaseError(
568
+ BASE_UNAVAILABLE,
569
+ 'lock missing acceptedArtifactManifestDigest',
570
+ {},
571
+ );
572
+ }
573
+
574
+ // Validate repository identity matches via remoteUrlHash (not absolute gitDir)
575
+ const identity = await readRepositoryIdentity(root);
576
+ if (identity.remoteUrlHash !== lock.repositoryIdentity.remoteUrlHash) {
577
+ throw new ReleaseError(
578
+ LOCK_MIGRATION_REQUIRED,
579
+ 'lock repositoryIdentity.remoteUrlHash does not match current repository',
580
+ {
581
+ field: 'repositoryIdentity',
582
+ lockRemoteUrlHash: lock.repositoryIdentity.remoteUrlHash,
583
+ currentRemoteUrlHash: identity.remoteUrlHash,
584
+ },
585
+ );
586
+ }
587
+
588
+ // Validate policy digest consistency
589
+ if (policyDigest && lock.policyDigest && policyDigest !== lock.policyDigest) {
590
+ throw new ReleaseError(
591
+ LOCK_MIGRATION_REQUIRED,
592
+ 'policy digest changed since lock was created',
593
+ {
594
+ field: 'policyDigest',
595
+ lockPolicyDigest: lock.policyDigest,
596
+ currentPolicyDigest: policyDigest,
597
+ },
598
+ );
599
+ }
600
+
601
+ // Validate policy artifact changes (path/ownership/driver)
602
+ validateLockPolicyConsistency(lock.entries, policyArtifacts);
603
+
604
+ // Compute artifact paths from lock entries for base search
605
+ const artifactPaths = Object.values(lock.entries).map((entry) => entry.path);
606
+ if (artifactPaths.some((path) => typeof path !== 'string' || path.length === 0)) {
607
+ throw new ReleaseError(
608
+ LOCK_MIGRATION_REQUIRED,
609
+ 'lock contains an entry without a canonical path',
610
+ { field: 'entries.path' },
611
+ );
612
+ }
613
+
614
+ // Walk first-parent history looking for matching manifest digest
615
+ const commits = await revListFirstParent(root);
616
+ for (const commit of commits) {
617
+ const entries = await readCommitEntries({ root, commit, paths: artifactPaths });
618
+ const entryArray = [...entries.values()];
619
+ const computedDigest = digestEntryManifest(entryArray);
620
+ if (computedDigest === lock.acceptedArtifactManifestDigest) {
621
+ return commit;
622
+ }
623
+ }
624
+
625
+ // No matching ancestor found — provide a fetch command bound to remoteUrlHash
626
+ throw new ReleaseError(
627
+ BASE_UNAVAILABLE,
628
+ 'no reachable ancestor matches the accepted artifact manifest',
629
+ {
630
+ remoteUrlHash: identity.remoteUrlHash,
631
+ lockManifestDigest: lock.acceptedArtifactManifestDigest,
632
+ nextAction: {
633
+ command: `git fetch --no-tags origin --deepen=100`,
634
+ },
635
+ },
636
+ );
637
+ }