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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|