release-skill 0.2.5 → 0.2.6
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 +21 -0
- package/INSTALL.md +32 -8
- package/INSTALL.zh-CN.md +28 -6
- package/README.md +26 -14
- package/README.zh-CN.md +24 -14
- 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 +1340 -614
- 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/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +1340 -614
- 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/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +1340 -614
- 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/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +1340 -614
- 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/bin/release-skill.bundle.mjs +1340 -614
- 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/src/commands/prepare.mjs +158 -1
- package/src/commands/publish.mjs +72 -0
- package/src/commands/reconcile.mjs +19 -0
- package/src/commands/setup.mjs +26 -0
- package/src/commands/verify.mjs +32 -0
- package/src/core/errors.mjs +12 -0
- package/src/core/source-authority.mjs +547 -0
package/src/commands/verify.mjs
CHANGED
|
@@ -41,8 +41,10 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
ReleaseError,
|
|
43
43
|
GATE_FAILED,
|
|
44
|
+
CONFIG_MISSING,
|
|
44
45
|
POST_PUBLISH_VERIFY_FAILED,
|
|
45
46
|
} from '../core/errors.mjs';
|
|
47
|
+
import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
|
|
46
48
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
47
49
|
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
48
50
|
import {
|
|
@@ -654,6 +656,13 @@ export async function verifyRelease(options) {
|
|
|
654
656
|
);
|
|
655
657
|
}
|
|
656
658
|
validatePlan(plan);
|
|
659
|
+
if (plan.production?.mode === 'github-npm-v1' && !plan.sourceAuthority) {
|
|
660
|
+
throw new ReleaseError(
|
|
661
|
+
CONFIG_MISSING,
|
|
662
|
+
'production verify requires a frozen sourceAuthority binding; the release must be re-prepared before publish',
|
|
663
|
+
{ gate: 'source-authority' },
|
|
664
|
+
);
|
|
665
|
+
}
|
|
657
666
|
|
|
658
667
|
// --- Set up directories ---
|
|
659
668
|
const runId = `verify-${Date.now()}`;
|
|
@@ -777,6 +786,29 @@ export async function verifyRelease(options) {
|
|
|
777
786
|
// Validate checkpoint mapping
|
|
778
787
|
validateRunCheckpointMapping(sourceRun, plan.externalActions ?? []);
|
|
779
788
|
|
|
789
|
+
// --- Source authority receipt verification ---
|
|
790
|
+
// If the plan declares sourceAuthority, the source publish run must
|
|
791
|
+
// contain a matching CONSISTENT receipt bound to this planDigest.
|
|
792
|
+
if (plan.sourceAuthority) {
|
|
793
|
+
await evidence.append({ phase: 'source-authority-receipt', status: 'started' });
|
|
794
|
+
|
|
795
|
+
const receiptResult = verifySourceAuthorityReceipt({ plan, run: sourceRun });
|
|
796
|
+
if (!receiptResult.passed) {
|
|
797
|
+
await evidence.append({
|
|
798
|
+
phase: 'source-authority-receipt',
|
|
799
|
+
status: 'failed',
|
|
800
|
+
reason: receiptResult.reason,
|
|
801
|
+
});
|
|
802
|
+
throw new ReleaseError(
|
|
803
|
+
GATE_FAILED,
|
|
804
|
+
`source authority receipt verification failed: ${receiptResult.reason}`,
|
|
805
|
+
{ reason: receiptResult.reason },
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
await evidence.append({ phase: 'source-authority-receipt', status: 'passed' });
|
|
810
|
+
}
|
|
811
|
+
|
|
780
812
|
// All checkpoints must be succeeded or skipped (no failed/pending),
|
|
781
813
|
// except marketplace install checkpoints which are re-verified by verify
|
|
782
814
|
// itself via consumer verification (human attestation or automatic).
|
package/src/core/errors.mjs
CHANGED
|
@@ -88,6 +88,12 @@ const EXIT_CODE_MAP = Object.freeze({
|
|
|
88
88
|
RELEASE_DOCS_REFRESH_STALE: 45,
|
|
89
89
|
RELEASE_DOCS_STALE: 46,
|
|
90
90
|
CONSUMER_VERIFICATION_DEFERRED: 47,
|
|
91
|
+
CONFIG_MISSING: 48,
|
|
92
|
+
REMOTE_UNAVAILABLE: 49,
|
|
93
|
+
REF_MISSING: 50,
|
|
94
|
+
NOT_DEFAULT: 51,
|
|
95
|
+
CONTENT_MISMATCH: 52,
|
|
96
|
+
DIRTY_SOURCE_INPUT: 53,
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
// ---- Error code constants ----
|
|
@@ -130,6 +136,12 @@ export const RELEASE_DOCS_CONFLICT = 'RELEASE_DOCS_CONFLICT';
|
|
|
130
136
|
export const RELEASE_DOCS_REFRESH_STALE = 'RELEASE_DOCS_REFRESH_STALE';
|
|
131
137
|
export const RELEASE_DOCS_STALE = 'RELEASE_DOCS_STALE';
|
|
132
138
|
export const CONSUMER_VERIFICATION_DEFERRED = 'CONSUMER_VERIFICATION_DEFERRED';
|
|
139
|
+
export const CONFIG_MISSING = 'CONFIG_MISSING';
|
|
140
|
+
export const REMOTE_UNAVAILABLE = 'REMOTE_UNAVAILABLE';
|
|
141
|
+
export const REF_MISSING = 'REF_MISSING';
|
|
142
|
+
export const NOT_DEFAULT = 'NOT_DEFAULT';
|
|
143
|
+
export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
|
|
144
|
+
export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
|
|
133
145
|
|
|
134
146
|
/**
|
|
135
147
|
* Typed error for release-skill operations.
|
|
@@ -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
|
+
}
|