release-skill 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/INSTALL.md +40 -16
- package/INSTALL.zh-CN.md +34 -12
- package/README.md +42 -15
- package/README.zh-CN.md +36 -15
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2671 -1552
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +67 -0
- package/adapters/claude/schemas/release-project.schema.json +6 -0
- package/adapters/claude/schemas/release-run.schema.json +65 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +5 -0
- package/adapters/claude/skills/release-setup/SKILL.md +10 -1
- package/adapters/claude/skills/release-verify/SKILL.md +4 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2671 -1552
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +67 -0
- package/adapters/codex/schemas/release-project.schema.json +6 -0
- package/adapters/codex/schemas/release-run.schema.json +65 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +5 -0
- package/adapters/codex/skills/release-setup/SKILL.md +10 -1
- package/adapters/codex/skills/release-verify/SKILL.md +4 -2
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2671 -1552
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +67 -0
- package/adapters/kimi/schemas/release-project.schema.json +6 -0
- package/adapters/kimi/schemas/release-run.schema.json +65 -0
- package/adapters/kimi/skills/release-prepare/SKILL.md +5 -0
- package/adapters/kimi/skills/release-setup/SKILL.md +10 -1
- package/adapters/kimi/skills/release-verify/SKILL.md +4 -2
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +2671 -1552
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +67 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +6 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +65 -0
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -0
- package/adapters/workbuddy/skills/release-setup/SKILL.md +10 -1
- package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -2
- package/bin/release-skill.bundle.mjs +2671 -1552
- package/package.json +1 -1
- package/references/.render-manifest.json +4 -4
- package/references/02-project-config.md +28 -2
- package/references/05-evidence-and-errors.md +6 -0
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +67 -0
- package/schemas/release-project.schema.json +6 -0
- package/schemas/release-run.schema.json +65 -0
- package/skills/release-prepare/SKILL.md +5 -0
- package/skills/release-setup/SKILL.md +10 -1
- package/skills/release-verify/SKILL.md +4 -2
- package/skills-src/release-prepare/SKILL.md +5 -0
- package/skills-src/release-setup/SKILL.md +10 -1
- package/skills-src/release-verify/SKILL.md +4 -2
- package/src/adapters/npm.mjs +54 -2
- package/src/adapters/plugin-marketplace.mjs +4 -29
- package/src/commands/prepare.mjs +161 -4
- package/src/commands/publish.mjs +74 -2
- package/src/commands/reconcile.mjs +58 -0
- package/src/commands/setup.mjs +194 -0
- package/src/commands/verify.mjs +60 -1
- package/src/core/errors.mjs +12 -0
- package/src/core/source-authority.mjs +547 -0
- package/src/npm/npm-entry-closure.mjs +195 -0
- package/src/platforms/codebuddy.mjs +19 -11
- package/src/platforms/codex.mjs +18 -10
- package/src/platforms/kimi.mjs +26 -39
- package/src/snapshot/frozen.mjs +51 -19
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace source-authority content closure.
|
|
3
|
+
*
|
|
4
|
+
* The closure binds the workspace-relative source files that feed a public
|
|
5
|
+
* release. Publish compares the frozen entries with the configured remote
|
|
6
|
+
* default branch before any external write.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { lstat, mkdtemp, readFile, readdir, realpath, rm } from 'node:fs/promises';
|
|
12
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
16
|
+
import {
|
|
17
|
+
CONFIG_INVALID,
|
|
18
|
+
CONFIG_MISSING,
|
|
19
|
+
CONTENT_MISMATCH,
|
|
20
|
+
DIRTY_SOURCE_INPUT,
|
|
21
|
+
NOT_DEFAULT,
|
|
22
|
+
REF_MISSING,
|
|
23
|
+
REMOTE_UNAVAILABLE,
|
|
24
|
+
ReleaseError,
|
|
25
|
+
} from './errors.mjs';
|
|
26
|
+
|
|
27
|
+
const execFile = promisify(execFileCb);
|
|
28
|
+
const REPOSITORY_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u;
|
|
29
|
+
const GIT_MODE_RE = /^(?:100644|100755)$/u;
|
|
30
|
+
|
|
31
|
+
export const SOURCE_INPUT_ALGORITHM_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compute the deterministic source-input closure for release units.
|
|
35
|
+
*
|
|
36
|
+
* `publicFiles.from` is workspace-root relative. `version.source` is relative
|
|
37
|
+
* to its release unit. Directories are recursively expanded. Symlinks and
|
|
38
|
+
* special files fail closed.
|
|
39
|
+
*/
|
|
40
|
+
export async function computeSourceInputClosure({ units, root }) {
|
|
41
|
+
const realRoot = await realpath(resolve(root));
|
|
42
|
+
const entriesByPath = new Map();
|
|
43
|
+
|
|
44
|
+
for (const unit of units ?? []) {
|
|
45
|
+
for (const mapping of unit.publicFiles ?? []) {
|
|
46
|
+
if (typeof mapping?.from !== 'string' || mapping.from.length === 0) continue;
|
|
47
|
+
await collectPath({
|
|
48
|
+
absolutePath: resolveInside(realRoot, mapping.from),
|
|
49
|
+
root: realRoot,
|
|
50
|
+
entriesByPath,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const versionSource = unit.version?.source;
|
|
55
|
+
if (typeof versionSource === 'string' && versionSource.length > 0) {
|
|
56
|
+
const unitRoot = resolveInside(realRoot, unit.source ?? '.');
|
|
57
|
+
await collectPath({
|
|
58
|
+
absolutePath: resolveInside(unitRoot, versionSource, realRoot),
|
|
59
|
+
root: realRoot,
|
|
60
|
+
entriesByPath,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entries = [...entriesByPath.values()]
|
|
66
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
67
|
+
const digest = computeEntriesDigest(entries);
|
|
68
|
+
return {
|
|
69
|
+
algorithmVersion: SOURCE_INPUT_ALGORITHM_VERSION,
|
|
70
|
+
entries,
|
|
71
|
+
digest,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function computeEntriesDigest(entries) {
|
|
76
|
+
return sha256Hex(canonicalJson(entries.map(({ path, digest, mode }) => ({
|
|
77
|
+
digest,
|
|
78
|
+
mode,
|
|
79
|
+
path,
|
|
80
|
+
}))));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function collectPath({ absolutePath, root, entriesByPath }) {
|
|
84
|
+
let stat;
|
|
85
|
+
try {
|
|
86
|
+
stat = await lstat(absolutePath);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new ReleaseError(
|
|
89
|
+
CONFIG_INVALID,
|
|
90
|
+
`source-input closure cannot stat "${toRelative(root, absolutePath)}": ${error.message}`,
|
|
91
|
+
{ cause: error.code ?? 'UNKNOWN', path: toRelative(root, absolutePath) },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rel = toRelative(root, absolutePath);
|
|
96
|
+
if (stat.isSymbolicLink()) {
|
|
97
|
+
throw new ReleaseError(
|
|
98
|
+
CONFIG_INVALID,
|
|
99
|
+
`source-input closure rejects symlink "${rel}"`,
|
|
100
|
+
{ path: rel },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const physicalPath = await realpath(absolutePath);
|
|
104
|
+
if (physicalPath !== absolutePath) {
|
|
105
|
+
throw new ReleaseError(
|
|
106
|
+
CONFIG_INVALID,
|
|
107
|
+
`source-input closure rejects symlinked ancestor for "${rel}"`,
|
|
108
|
+
{ path: rel },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (stat.isDirectory()) {
|
|
112
|
+
const children = await readdir(absolutePath, { withFileTypes: true });
|
|
113
|
+
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
114
|
+
for (const child of children) {
|
|
115
|
+
await collectPath({
|
|
116
|
+
absolutePath: join(absolutePath, child.name),
|
|
117
|
+
root,
|
|
118
|
+
entriesByPath,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!stat.isFile()) {
|
|
124
|
+
throw new ReleaseError(
|
|
125
|
+
CONFIG_INVALID,
|
|
126
|
+
`source-input closure rejects non-regular file "${rel}"`,
|
|
127
|
+
{ path: rel },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const content = await readFile(absolutePath);
|
|
132
|
+
entriesByPath.set(rel, {
|
|
133
|
+
path: rel,
|
|
134
|
+
digest: sha256Hex(content),
|
|
135
|
+
mode: normalizeLocalGitMode(stat.mode),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveInside(base, candidate, containmentRoot = base) {
|
|
140
|
+
const resolved = resolve(base, candidate);
|
|
141
|
+
const root = resolve(containmentRoot);
|
|
142
|
+
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
|
|
143
|
+
throw new ReleaseError(
|
|
144
|
+
CONFIG_INVALID,
|
|
145
|
+
`source-input closure path escapes workspace root: "${candidate}"`,
|
|
146
|
+
{ path: candidate },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return resolved;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toRelative(root, absolutePath) {
|
|
153
|
+
const rel = relative(root, absolutePath).split(sep).join('/');
|
|
154
|
+
if (!rel || rel === '.' || rel.startsWith('../') || rel === '..') {
|
|
155
|
+
throw new ReleaseError(
|
|
156
|
+
CONFIG_INVALID,
|
|
157
|
+
`source-input closure path is outside the workspace: "${absolutePath}"`,
|
|
158
|
+
{ path: absolutePath },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return rel;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeLocalGitMode(mode) {
|
|
165
|
+
return (mode & 0o111) === 0 ? '100644' : '100755';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check only frozen source-input paths for staged, unstaged or untracked
|
|
170
|
+
* changes. Unrelated dirty files remain allowed.
|
|
171
|
+
*/
|
|
172
|
+
export async function checkSourceInputDirty({ closure, root, execFn = execFile }) {
|
|
173
|
+
const paths = (closure?.entries ?? []).map((entry) => entry.path);
|
|
174
|
+
if (paths.length === 0) return { dirty: false, dirtyPaths: [] };
|
|
175
|
+
|
|
176
|
+
let stdout;
|
|
177
|
+
try {
|
|
178
|
+
({ stdout } = await execFn(
|
|
179
|
+
'git',
|
|
180
|
+
[
|
|
181
|
+
'status',
|
|
182
|
+
'--porcelain=v1',
|
|
183
|
+
'-z',
|
|
184
|
+
'--untracked-files=all',
|
|
185
|
+
'--ignored=matching',
|
|
186
|
+
'--',
|
|
187
|
+
...paths,
|
|
188
|
+
],
|
|
189
|
+
{ cwd: root, encoding: 'utf8', shell: false },
|
|
190
|
+
));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw new ReleaseError(
|
|
193
|
+
DIRTY_SOURCE_INPUT,
|
|
194
|
+
`cannot check source-input dirty status: ${error.message}`,
|
|
195
|
+
{ cause: error.code ?? 'UNKNOWN' },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const dirtyPaths = [];
|
|
200
|
+
const records = String(stdout).split('\0').filter(Boolean);
|
|
201
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
202
|
+
const record = records[index];
|
|
203
|
+
if (record.length < 4) continue;
|
|
204
|
+
const status = record.slice(0, 2);
|
|
205
|
+
const recordPath = record.slice(3);
|
|
206
|
+
// Ignored inputs are still untracked inputs. A file explicitly selected
|
|
207
|
+
// for publication must not escape the prepare gate merely because a
|
|
208
|
+
// .gitignore rule hides it from ordinary status output.
|
|
209
|
+
dirtyPaths.push(recordPath);
|
|
210
|
+
if (status.startsWith('R') || status.startsWith('C')) index += 1;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
dirty: dirtyPaths.length > 0,
|
|
214
|
+
dirtyPaths: [...new Set(dirtyPaths)].sort(),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Prove that the bytes copied into every frozen public snapshot came from
|
|
220
|
+
* the same source-input closure that will later be checked on the remote
|
|
221
|
+
* default branch.
|
|
222
|
+
*
|
|
223
|
+
* This closes the prepare-time interval between closure calculation and
|
|
224
|
+
* snapshot construction. The snapshot builder records the exact source
|
|
225
|
+
* path, content digest and source mode for every copied file.
|
|
226
|
+
*/
|
|
227
|
+
export function verifySnapshotSourcesMatchClosure({ closure, unitResults }) {
|
|
228
|
+
if (!closure || !Array.isArray(closure.entries)) {
|
|
229
|
+
return failure(CONFIG_INVALID, 'source-input closure is missing');
|
|
230
|
+
}
|
|
231
|
+
const closureByPath = new Map(
|
|
232
|
+
closure.entries.map((entry) => [entry.path, entry]),
|
|
233
|
+
);
|
|
234
|
+
const mismatchedPaths = [];
|
|
235
|
+
|
|
236
|
+
for (const { manifest } of unitResults ?? []) {
|
|
237
|
+
for (const entry of manifest?.entries ?? []) {
|
|
238
|
+
const expected = closureByPath.get(entry.from);
|
|
239
|
+
const actualMode = normalizeSnapshotGitMode(entry.mode);
|
|
240
|
+
if (
|
|
241
|
+
!expected
|
|
242
|
+
|| entry.hash !== expected.digest
|
|
243
|
+
|| actualMode !== expected.mode
|
|
244
|
+
) {
|
|
245
|
+
mismatchedPaths.push(entry.from);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (mismatchedPaths.length > 0) {
|
|
251
|
+
const paths = [...new Set(mismatchedPaths)].sort();
|
|
252
|
+
return {
|
|
253
|
+
passed: false,
|
|
254
|
+
error: {
|
|
255
|
+
code: DIRTY_SOURCE_INPUT,
|
|
256
|
+
message: `frozen snapshots differ from ${paths.length} source-input closure file(s)`,
|
|
257
|
+
paths,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
passed: true,
|
|
263
|
+
observation: {
|
|
264
|
+
snapshotSourceCount: new Set(
|
|
265
|
+
(unitResults ?? []).flatMap(({ manifest }) => (
|
|
266
|
+
(manifest?.entries ?? []).map((entry) => entry.from)
|
|
267
|
+
)),
|
|
268
|
+
).size,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeSnapshotGitMode(mode) {
|
|
274
|
+
if (typeof mode === 'string' && GIT_MODE_RE.test(mode)) return mode;
|
|
275
|
+
if (typeof mode === 'number') return normalizeLocalGitMode(mode);
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compare a frozen closure with a repository's actual remote default branch.
|
|
281
|
+
*
|
|
282
|
+
* Tests may inject `readRemoteFn(repo, branch, path)`; production uses one
|
|
283
|
+
* shallow fetch in a temporary bare repository and never mutates user refs.
|
|
284
|
+
*/
|
|
285
|
+
export async function verifyRemoteSourceContent({
|
|
286
|
+
sourceRepository,
|
|
287
|
+
defaultBranch,
|
|
288
|
+
closure,
|
|
289
|
+
readRemoteFn,
|
|
290
|
+
execFn = execFile,
|
|
291
|
+
}) {
|
|
292
|
+
if (!REPOSITORY_RE.test(sourceRepository ?? '')) {
|
|
293
|
+
return failure(CONFIG_MISSING, 'project.sourceRepository must be an explicit GitHub owner/repo');
|
|
294
|
+
}
|
|
295
|
+
if (typeof defaultBranch !== 'string' || defaultBranch.length === 0) {
|
|
296
|
+
return failure(CONFIG_MISSING, 'project.defaultBranch must be an explicit branch name');
|
|
297
|
+
}
|
|
298
|
+
if (!closure || closure.algorithmVersion !== SOURCE_INPUT_ALGORITHM_VERSION) {
|
|
299
|
+
return failure(CONFIG_INVALID, 'source-input closure algorithm is unsupported');
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(closure.entries) || computeEntriesDigest(closure.entries) !== closure.digest) {
|
|
302
|
+
return failure(CONFIG_INVALID, 'source-input closure entries do not match the frozen digest');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (readRemoteFn) {
|
|
306
|
+
return verifyWithInjectedReader({
|
|
307
|
+
closure,
|
|
308
|
+
defaultBranch,
|
|
309
|
+
readRemoteFn,
|
|
310
|
+
sourceRepository,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return verifyWithTemporaryGit({
|
|
314
|
+
closure,
|
|
315
|
+
defaultBranch,
|
|
316
|
+
execFn,
|
|
317
|
+
sourceRepository,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function verifyWithInjectedReader({
|
|
322
|
+
closure,
|
|
323
|
+
defaultBranch,
|
|
324
|
+
readRemoteFn,
|
|
325
|
+
sourceRepository,
|
|
326
|
+
}) {
|
|
327
|
+
const mismatchedPaths = [];
|
|
328
|
+
for (const entry of closure.entries) {
|
|
329
|
+
let result;
|
|
330
|
+
try {
|
|
331
|
+
result = await readRemoteFn(sourceRepository, defaultBranch, entry.path);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return failure(REMOTE_UNAVAILABLE, error.message);
|
|
334
|
+
}
|
|
335
|
+
const classified = classifyRemoteStatus(result, sourceRepository, defaultBranch);
|
|
336
|
+
if (classified) return classified;
|
|
337
|
+
if (
|
|
338
|
+
sha256Hex(Buffer.isBuffer(result.content) ? result.content : Buffer.from(result.content))
|
|
339
|
+
!== entry.digest
|
|
340
|
+
|| result.mode !== entry.mode
|
|
341
|
+
) {
|
|
342
|
+
mismatchedPaths.push(entry.path);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return mismatchedPaths.length > 0
|
|
346
|
+
? mismatch(defaultBranch, mismatchedPaths)
|
|
347
|
+
: {
|
|
348
|
+
passed: true,
|
|
349
|
+
observation: {
|
|
350
|
+
defaultBranch,
|
|
351
|
+
entryCount: closure.entries.length,
|
|
352
|
+
sourceRepository,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function classifyRemoteStatus(result, repository, branch) {
|
|
358
|
+
if (result?.status === 'ok') return null;
|
|
359
|
+
if (result?.status === 'ref_missing') {
|
|
360
|
+
return failure(
|
|
361
|
+
REF_MISSING,
|
|
362
|
+
result.error ?? `remote ref "${branch}" does not exist in "${repository}"`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (result?.status === 'not_default') {
|
|
366
|
+
return failure(
|
|
367
|
+
NOT_DEFAULT,
|
|
368
|
+
result.error ?? `"${branch}" is not the default branch of "${repository}"`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return failure(
|
|
372
|
+
REMOTE_UNAVAILABLE,
|
|
373
|
+
result?.error ?? `remote source "${repository}" is unavailable`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function verifyWithTemporaryGit({
|
|
378
|
+
closure,
|
|
379
|
+
defaultBranch,
|
|
380
|
+
execFn,
|
|
381
|
+
sourceRepository,
|
|
382
|
+
}) {
|
|
383
|
+
const repositoryUrl = `https://github.com/${sourceRepository}.git`;
|
|
384
|
+
let observed;
|
|
385
|
+
try {
|
|
386
|
+
({ stdout: observed } = await execFn(
|
|
387
|
+
'git',
|
|
388
|
+
['ls-remote', '--symref', repositoryUrl, 'HEAD', `refs/heads/${defaultBranch}`],
|
|
389
|
+
{ encoding: 'utf8', shell: false, timeout: 60_000 },
|
|
390
|
+
));
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return failure(REMOTE_UNAVAILABLE, `cannot observe "${sourceRepository}": ${error.message}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const lines = String(observed).split(/\r?\n/u).filter(Boolean);
|
|
396
|
+
const headSymref = lines.find((line) => line.startsWith('ref: refs/heads/'));
|
|
397
|
+
const actualDefault = headSymref?.match(/^ref: refs\/heads\/(.+)\tHEAD$/u)?.[1] ?? null;
|
|
398
|
+
if (!actualDefault) {
|
|
399
|
+
return failure(REMOTE_UNAVAILABLE, `remote default branch is not observable for "${sourceRepository}"`);
|
|
400
|
+
}
|
|
401
|
+
if (actualDefault !== defaultBranch) {
|
|
402
|
+
return failure(
|
|
403
|
+
NOT_DEFAULT,
|
|
404
|
+
`configured defaultBranch "${defaultBranch}" does not match remote default "${actualDefault}"`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const branchLine = lines.find((line) => line.endsWith(`\trefs/heads/${defaultBranch}`));
|
|
408
|
+
if (!branchLine) {
|
|
409
|
+
return failure(REF_MISSING, `remote ref "refs/heads/${defaultBranch}" does not exist`);
|
|
410
|
+
}
|
|
411
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'release-skill-source-authority-'));
|
|
412
|
+
try {
|
|
413
|
+
await execFn('git', ['init', '--bare', tempRoot], {
|
|
414
|
+
encoding: 'utf8',
|
|
415
|
+
shell: false,
|
|
416
|
+
timeout: 30_000,
|
|
417
|
+
});
|
|
418
|
+
await execFn(
|
|
419
|
+
'git',
|
|
420
|
+
[
|
|
421
|
+
'-C',
|
|
422
|
+
tempRoot,
|
|
423
|
+
'fetch',
|
|
424
|
+
'--depth=1',
|
|
425
|
+
repositoryUrl,
|
|
426
|
+
`refs/heads/${defaultBranch}:refs/source-authority/target`,
|
|
427
|
+
],
|
|
428
|
+
{ encoding: 'utf8', shell: false, timeout: 120_000 },
|
|
429
|
+
);
|
|
430
|
+
const { stdout: fetchedCommitOutput } = await execFn(
|
|
431
|
+
'git',
|
|
432
|
+
['-C', tempRoot, 'rev-parse', 'refs/source-authority/target'],
|
|
433
|
+
{ encoding: 'utf8', shell: false, timeout: 30_000 },
|
|
434
|
+
);
|
|
435
|
+
const observedCommit = String(fetchedCommitOutput).trim();
|
|
436
|
+
const { stdout: treeOutput } = await execFn(
|
|
437
|
+
'git',
|
|
438
|
+
['-C', tempRoot, 'ls-tree', '-r', '-z', 'refs/source-authority/target'],
|
|
439
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: false, timeout: 30_000 },
|
|
440
|
+
);
|
|
441
|
+
const tree = parseLsTree(treeOutput);
|
|
442
|
+
const mismatchedPaths = [];
|
|
443
|
+
for (const expected of closure.entries) {
|
|
444
|
+
const actual = tree.get(expected.path);
|
|
445
|
+
if (!actual || actual.type !== 'blob' || actual.mode !== expected.mode) {
|
|
446
|
+
mismatchedPaths.push(expected.path);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const { stdout: content } = await execFn(
|
|
450
|
+
'git',
|
|
451
|
+
['-C', tempRoot, 'cat-file', 'blob', actual.objectId],
|
|
452
|
+
{ encoding: null, maxBuffer: 64 * 1024 * 1024, shell: false, timeout: 30_000 },
|
|
453
|
+
);
|
|
454
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
455
|
+
if (sha256Hex(bytes) !== expected.digest) mismatchedPaths.push(expected.path);
|
|
456
|
+
}
|
|
457
|
+
return mismatchedPaths.length > 0
|
|
458
|
+
? mismatch(defaultBranch, mismatchedPaths)
|
|
459
|
+
: {
|
|
460
|
+
passed: true,
|
|
461
|
+
observation: {
|
|
462
|
+
defaultBranch,
|
|
463
|
+
entryCount: closure.entries.length,
|
|
464
|
+
observedCommit,
|
|
465
|
+
sourceRepository,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return failure(REMOTE_UNAVAILABLE, `cannot read remote source tree: ${error.message}`);
|
|
470
|
+
} finally {
|
|
471
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseLsTree(output) {
|
|
476
|
+
const result = new Map();
|
|
477
|
+
for (const record of String(output).split('\0').filter(Boolean)) {
|
|
478
|
+
const match = record.match(/^([0-9]{6}) ([a-z]+) ([0-9a-f]{40,64})\t(.+)$/u);
|
|
479
|
+
if (!match) continue;
|
|
480
|
+
result.set(match[4], {
|
|
481
|
+
mode: match[1],
|
|
482
|
+
objectId: match[3],
|
|
483
|
+
type: match[2],
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function mismatch(defaultBranch, paths) {
|
|
490
|
+
const uniquePaths = [...new Set(paths)].sort();
|
|
491
|
+
return {
|
|
492
|
+
passed: false,
|
|
493
|
+
error: {
|
|
494
|
+
code: CONTENT_MISMATCH,
|
|
495
|
+
message: `remote default branch "${defaultBranch}" differs from ${uniquePaths.length} frozen source input(s)`,
|
|
496
|
+
paths: uniquePaths,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function failure(code, message) {
|
|
502
|
+
return { passed: false, error: { code, message } };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Validate a publish receipt against the frozen plan authority. */
|
|
506
|
+
export function verifySourceAuthorityReceipt({ plan, run }) {
|
|
507
|
+
const authority = plan.sourceAuthority;
|
|
508
|
+
if (!authority) return { passed: true };
|
|
509
|
+
const matching = (run.sourceAuthorityReceipts ?? []).find((receipt) => (
|
|
510
|
+
receipt.sourceRepository === authority.sourceRepository
|
|
511
|
+
&& receipt.defaultBranch === authority.defaultBranch
|
|
512
|
+
&& receipt.inputDigest === authority.inputDigest
|
|
513
|
+
&& receipt.algorithmVersion === authority.algorithmVersion
|
|
514
|
+
&& receipt.entryCount === authority.entries.length
|
|
515
|
+
&& receipt.planDigest === plan.digest
|
|
516
|
+
&& receipt.result === 'CONSISTENT'
|
|
517
|
+
));
|
|
518
|
+
return matching
|
|
519
|
+
? { passed: true, receipt: matching }
|
|
520
|
+
: {
|
|
521
|
+
passed: false,
|
|
522
|
+
reason: 'publish run has no CONSISTENT source-authority receipt bound to this plan digest',
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Create the digest-bound receipt persisted by publish. */
|
|
527
|
+
export function createSourceAuthorityReceipt({
|
|
528
|
+
plan,
|
|
529
|
+
result,
|
|
530
|
+
observation,
|
|
531
|
+
mismatchedPaths,
|
|
532
|
+
clock = () => new Date().toISOString(),
|
|
533
|
+
}) {
|
|
534
|
+
const authority = plan.sourceAuthority;
|
|
535
|
+
return {
|
|
536
|
+
algorithmVersion: authority.algorithmVersion,
|
|
537
|
+
defaultBranch: authority.defaultBranch,
|
|
538
|
+
entryCount: authority.entries.length,
|
|
539
|
+
inputDigest: authority.inputDigest,
|
|
540
|
+
planDigest: plan.digest,
|
|
541
|
+
result,
|
|
542
|
+
sourceRepository: authority.sourceRepository,
|
|
543
|
+
verifiedAt: clock(),
|
|
544
|
+
...(observation?.observedCommit ? { observedCommit: observation.observedCommit } : {}),
|
|
545
|
+
...(mismatchedPaths?.length ? { mismatchedPaths: [...new Set(mismatchedPaths)].sort() } : {}),
|
|
546
|
+
};
|
|
547
|
+
}
|