wendkeep 0.78.0 → 0.79.0

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.
@@ -0,0 +1,547 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * External provenance is deliberately collected in this module and kept
8
+ * separate from the pure gate. Every command receives an argument array and
9
+ * an explicit non-shell option. The default commands are replaceable in tests
10
+ * and by callers that already have an authenticated client.
11
+ */
12
+
13
+ const SOURCE_TIMEOUT_MS = 15_000;
14
+ const SAFE_REF = /^(?!-)[A-Za-z0-9_./@^~:+-]+$/;
15
+ const SAFE_PACKAGE = /^(?:@[A-Za-z0-9._~-]+\/)?[A-Za-z0-9._~-]+$/;
16
+ const GITHUB_HOSTS = new Set(['github.com', 'api.github.com']);
17
+
18
+ function result(kind, state, fields = {}) {
19
+ return {
20
+ kind,
21
+ state,
22
+ ok: state === 'verified',
23
+ reasonCodes: [],
24
+ diagnostics: [],
25
+ ...fields,
26
+ };
27
+ }
28
+
29
+ function failure(kind, state, code, fields = {}) {
30
+ return result(kind, state, {
31
+ reasonCodes: [code],
32
+ diagnostics: [{ code }],
33
+ ...fields,
34
+ });
35
+ }
36
+
37
+ function commandOptions(cwd) {
38
+ return {
39
+ cwd,
40
+ encoding: 'utf8',
41
+ timeout: SOURCE_TIMEOUT_MS,
42
+ shell: false,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ };
45
+ }
46
+
47
+ function outputOf(value) {
48
+ if (Buffer.isBuffer(value)) return value.toString('utf8');
49
+ if (value && typeof value === 'object' && Object.hasOwn(value, 'stdout')) {
50
+ return outputOf(value.stdout);
51
+ }
52
+ return String(value ?? '');
53
+ }
54
+
55
+ function parseJsonOutput(raw) {
56
+ const text = outputOf(raw).trim();
57
+ if (!text) return undefined;
58
+ try {
59
+ return JSON.parse(text);
60
+ } catch {
61
+ // npm and gh may print a warning before JSON. Do not return the warning;
62
+ // only parse a complete JSON value from the first object/array boundary.
63
+ const starts = [text.indexOf('{'), text.indexOf('[')].filter((index) => index >= 0);
64
+ const start = starts.length ? Math.min(...starts) : -1;
65
+ if (start < 0) return undefined;
66
+ const endObject = text.lastIndexOf('}');
67
+ const endArray = text.lastIndexOf(']');
68
+ const end = Math.max(endObject, endArray);
69
+ if (end < start) return undefined;
70
+ try {
71
+ return JSON.parse(text.slice(start, end + 1));
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ }
77
+
78
+ function executeFile(execute, command, args, cwd) {
79
+ return execute(command, args, commandOptions(cwd));
80
+ }
81
+
82
+ function executeGithubApi(execute, path) {
83
+ return executeFile(execute, 'gh', ['api', '--hostname', 'github.com', path], undefined);
84
+ }
85
+
86
+ function safeRef(ref) {
87
+ return typeof ref === 'string' && ref.length > 0 && ref.length <= 512 && SAFE_REF.test(ref);
88
+ }
89
+
90
+ function safePath(path) {
91
+ return typeof path === 'string'
92
+ && path.length > 0
93
+ && !path.startsWith('/')
94
+ && !path.includes('..')
95
+ && !/[\u0000-\u001f\u007f]/.test(path);
96
+ }
97
+
98
+ function safePackageName(name) {
99
+ return typeof name === 'string' && name.length <= 214 && SAFE_PACKAGE.test(name);
100
+ }
101
+
102
+ function safeVersion(version) {
103
+ return typeof version === 'string' && version.length > 0 && version.length <= 128
104
+ && !/[\s\u0000-\u001f\u007f]/.test(version);
105
+ }
106
+
107
+ function safeTag(tag) {
108
+ return typeof tag === 'string' && tag.length > 0 && tag.length <= 256 && safeRef(tag);
109
+ }
110
+
111
+ function errorCode(error) {
112
+ const code = String(error?.code || '').toUpperCase();
113
+ if (code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || code === 'ABORT_ERR') {
114
+ return 'PROVENANCE_SOURCE_TIMEOUT';
115
+ }
116
+ return 'PROVENANCE_SOURCE_UNAVAILABLE';
117
+ }
118
+
119
+ function normalizeRepository(repository) {
120
+ if (typeof repository !== 'string') return '';
121
+ let value = repository.trim();
122
+ if (!value) return '';
123
+ if (value.startsWith('git+')) value = value.slice(4);
124
+ const scp = value.match(/^git@([^:]+):(.+)$/i);
125
+ if (scp) {
126
+ if (scp[1].toLowerCase() !== 'github.com') return '';
127
+ value = scp[2];
128
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
129
+ try {
130
+ const url = new URL(value);
131
+ if (!['git:', 'http:', 'https:', 'ssh:'].includes(url.protocol)
132
+ || url.hostname.toLowerCase() !== 'github.com') return '';
133
+ value = url.pathname.replace(/^\//, '');
134
+ } catch {
135
+ return '';
136
+ }
137
+ }
138
+ value = value.replace(/\/$/, '').replace(/\.git$/i, '');
139
+ const match = value.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/);
140
+ if (!match || match[1].endsWith('-') || match[2] === '.' || match[2] === '..') return '';
141
+ return value;
142
+ }
143
+
144
+ function repositoryFromPath(pathname) {
145
+ const match = String(pathname).match(/^\/repos\/([^/]+\/[^/]+)(?:\/|$)/);
146
+ return match ? match[1] : '';
147
+ }
148
+
149
+ function parseGithubLocator(locator, expectedRepository) {
150
+ const expected = normalizeRepository(expectedRepository);
151
+ if (!expected) return { state: 'unproven', code: 'PROVENANCE_REPOSITORY_MISSING' };
152
+
153
+ if (locator && typeof locator === 'object' && !Array.isArray(locator)) {
154
+ if (locator.repository && normalizeRepository(locator.repository) !== expected) {
155
+ return { state: 'conflict', code: 'PROVENANCE_REPOSITORY_MISMATCH' };
156
+ }
157
+ if (locator.url !== undefined) return parseGithubLocator(locator.url, expected);
158
+ if (locator.apiPath !== undefined) return parseGithubLocator(locator.apiPath, expected);
159
+ const runId = String(locator.runId ?? locator.run_id ?? '').trim();
160
+ if (/^[0-9]+$/.test(runId)) {
161
+ return { state: 'ok', repository: expected, path: `/repos/${expected}/actions/runs/${runId}` };
162
+ }
163
+ return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
164
+ }
165
+
166
+ if (typeof locator !== 'string' || !locator.trim()) {
167
+ return { state: 'unproven', code: 'PROVENANCE_LOCATOR_MISSING' };
168
+ }
169
+ const value = locator.trim();
170
+ if (value.startsWith('/repos/')) {
171
+ const repository = repositoryFromPath(value);
172
+ if (!repository) return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
173
+ if (repository !== expected) return { state: 'conflict', code: 'PROVENANCE_REPOSITORY_MISMATCH' };
174
+ if (!/^\/repos\/[^/]+\/[^/]+\/actions\/runs\/[0-9]+(?:$|[/?])/.test(value)) {
175
+ return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
176
+ }
177
+ return { state: 'ok', repository, path: value.split('?')[0] };
178
+ }
179
+ if (!value.startsWith('http://') && !value.startsWith('https://')) {
180
+ return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
181
+ }
182
+ let url;
183
+ try {
184
+ url = new URL(value);
185
+ } catch {
186
+ return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
187
+ }
188
+ if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) {
189
+ return { state: 'conflict', code: 'PROVENANCE_REPOSITORY_MISMATCH' };
190
+ }
191
+ const actions = url.pathname.match(/^\/([^/]+)\/([^/]+)\/actions\/runs\/([0-9]+)(?:\/|$)/);
192
+ const apiRepository = actions ? `${actions[1]}/${actions[2]}` : '';
193
+ if (!actions || !apiRepository) return { state: 'reported', code: 'PROVENANCE_LOCATOR_INVALID' };
194
+ if (apiRepository !== expected) return { state: 'conflict', code: 'PROVENANCE_REPOSITORY_MISMATCH' };
195
+ return { state: 'ok', repository: apiRepository, path: `/repos/${apiRepository}/actions/runs/${actions[3]}` };
196
+ }
197
+
198
+ function sourceError(kind, error, fields = {}) {
199
+ return failure(kind, 'reported', errorCode(error), fields);
200
+ }
201
+
202
+ function mismatch(kind, code, fields = {}) {
203
+ return failure(kind, 'conflict', code, fields);
204
+ }
205
+
206
+ function parsePackage(raw) {
207
+ const packageJson = parseJsonOutput(raw);
208
+ if (!packageJson || typeof packageJson !== 'object' || Array.isArray(packageJson)) return undefined;
209
+ const name = String(packageJson.name || '');
210
+ const version = String(packageJson.version || '');
211
+ if (!safePackageName(name) || !safeVersion(version)) return undefined;
212
+ return { name, version };
213
+ }
214
+
215
+ function extractNotes(changelog, version) {
216
+ const lines = String(changelog || '').split(/\r?\n/);
217
+ const escaped = String(version).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
218
+ const header = new RegExp(`^##\\s*\\[${escaped}\\]\\s*[—–-]`);
219
+ const start = lines.findIndex((line) => header.test(line));
220
+ if (start < 0) return undefined;
221
+ const body = [];
222
+ for (let index = start + 1; index < lines.length; index += 1) {
223
+ if (/^##\s*\[/.test(lines[index])) break;
224
+ body.push(lines[index]);
225
+ }
226
+ return body.join('\n').trim();
227
+ }
228
+
229
+ /** Read a tracked file from a commit/ref. This never reads the worktree. */
230
+ export function readTextAtCommit(repoRoot, ref, path, { execute = execFileSync } = {}) {
231
+ if (!safeRef(ref)) throw new Error('unsafe git ref');
232
+ if (!safePath(path)) throw new Error('unsafe git path');
233
+ return outputOf(executeFile(execute, 'git', [
234
+ 'cat-file', 'blob', `${ref}:${path}`,
235
+ ], repoRoot));
236
+ }
237
+
238
+ export function readJsonAtCommit(repoRoot, ref, path, options = {}) {
239
+ const text = readTextAtCommit(repoRoot, ref, path, options);
240
+ const parsed = parseJsonOutput(text);
241
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid tracked JSON');
242
+ return parsed;
243
+ }
244
+
245
+ /**
246
+ * Resolve source and target, then read package/changelog from targetRef. The
247
+ * target subject is therefore stable even if the caller's worktree differs.
248
+ */
249
+ export function collectGitSubject({
250
+ repoRoot,
251
+ sourceRef = '',
252
+ targetRef,
253
+ execute = execFileSync,
254
+ } = {}) {
255
+ const kind = 'git-subject';
256
+ if (!repoRoot || !safeRef(targetRef) || (sourceRef && !safeRef(sourceRef))) {
257
+ return failure(kind, 'unproven', 'PROVENANCE_GIT_REF_INVALID');
258
+ }
259
+ let sourceCommit = '';
260
+ let targetCommit = '';
261
+ try {
262
+ if (sourceRef) sourceCommit = outputOf(executeFile(execute, 'git', [
263
+ 'rev-parse', '--verify', '--end-of-options', `${sourceRef}^{commit}`,
264
+ ], repoRoot)).trim();
265
+ targetCommit = outputOf(executeFile(execute, 'git', [
266
+ 'rev-parse', '--verify', '--end-of-options', `${targetRef}^{commit}`,
267
+ ], repoRoot)).trim();
268
+ } catch (error) {
269
+ return sourceError(kind, error, { sourceRef, targetRef });
270
+ }
271
+ if (!/^[0-9a-f]{40}$/i.test(targetCommit) || (sourceRef && !/^[0-9a-f]{40}$/i.test(sourceCommit))) {
272
+ return failure(kind, 'unproven', 'PROVENANCE_GIT_SUBJECT_UNRESOLVED', { sourceRef, targetRef });
273
+ }
274
+ let packageJson;
275
+ let changelog;
276
+ try {
277
+ packageJson = readJsonAtCommit(repoRoot, targetCommit, 'package.json', { execute });
278
+ changelog = readTextAtCommit(repoRoot, targetCommit, 'CHANGELOG.md', { execute });
279
+ } catch (error) {
280
+ return failure(kind, 'unproven', 'PROVENANCE_TARGET_ARTIFACT_MISSING', {
281
+ sourceRef, targetRef, sourceCommit, targetCommit,
282
+ });
283
+ }
284
+ const pkg = parsePackage(JSON.stringify(packageJson));
285
+ if (!pkg) return failure(kind, 'unproven', 'PROVENANCE_PACKAGE_INVALID', { sourceRef, targetRef, sourceCommit, targetCommit });
286
+ const notes = extractNotes(changelog, pkg.version);
287
+ if (notes === undefined) return failure(kind, 'unproven', 'PROVENANCE_CHANGELOG_VERSION_MISSING', {
288
+ sourceRef,
289
+ targetRef,
290
+ sourceCommit,
291
+ targetCommit,
292
+ commit: targetCommit,
293
+ package: pkg,
294
+ name: pkg.name,
295
+ version: pkg.version,
296
+ });
297
+ return result(kind, 'verified', {
298
+ sourceRef,
299
+ targetRef,
300
+ sourceCommit,
301
+ targetCommit,
302
+ commit: targetCommit,
303
+ package: pkg,
304
+ name: pkg.name,
305
+ version: pkg.version,
306
+ changelog,
307
+ notes,
308
+ });
309
+ }
310
+
311
+ export function collectCiObservation({
312
+ locator,
313
+ repository,
314
+ expectedCommit,
315
+ execute = execFileSync,
316
+ } = {}) {
317
+ const kind = 'ci';
318
+ if (!expectedCommit || !/^[0-9a-f]{40}$/i.test(String(expectedCommit))) {
319
+ return failure(kind, 'unproven', 'PROVENANCE_COMMIT_MISSING');
320
+ }
321
+ const parsed = parseGithubLocator(locator, repository);
322
+ if (parsed.state !== 'ok') return failure(kind, parsed.state, parsed.code, { repository: normalizeRepository(repository) });
323
+ try {
324
+ const raw = executeGithubApi(execute, parsed.path);
325
+ const response = parseJsonOutput(raw);
326
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
327
+ return failure(kind, 'reported', 'PROVENANCE_SOURCE_UNAVAILABLE', { repository: parsed.repository });
328
+ }
329
+ const observedRepository = normalizeRepository(response.repository?.full_name
330
+ || response.head_repository?.full_name || parsed.repository);
331
+ if (observedRepository && observedRepository !== parsed.repository) {
332
+ return mismatch(kind, 'PROVENANCE_REPOSITORY_MISMATCH', { repository: parsed.repository, observedRepository });
333
+ }
334
+ const commit = String(response.head_sha || response.head_commit?.id || response.commit || '').trim();
335
+ if (!commit) return failure(kind, 'reported', 'PROVENANCE_COMMIT_UNOBSERVED', { repository: parsed.repository });
336
+ if (commit !== expectedCommit) return mismatch(kind, 'PROVENANCE_COMMIT_MISMATCH', { repository: parsed.repository, commit, expectedCommit });
337
+ if (!response.conclusion) {
338
+ return failure(kind, 'reported', 'PROVENANCE_CI_CONCLUSION_UNOBSERVED', { repository: parsed.repository, commit });
339
+ }
340
+ if (String(response.conclusion).toLowerCase() !== 'success') {
341
+ return mismatch(kind, 'PROVENANCE_CI_NOT_SUCCESS', { repository: parsed.repository, commit });
342
+ }
343
+ if (String(response.status || '').toLowerCase() !== 'completed') {
344
+ return failure(kind, 'reported', 'PROVENANCE_CI_INCOMPLETE', { repository: parsed.repository, commit });
345
+ }
346
+ return result(kind, 'verified', {
347
+ repository: parsed.repository,
348
+ locator: parsed.path,
349
+ commit,
350
+ conclusion: response.conclusion,
351
+ status: 'success',
352
+ workflow_status: 'completed',
353
+ observed: {
354
+ repository: parsed.repository, commit, conclusion: response.conclusion,
355
+ status: 'success', workflow_status: 'completed',
356
+ },
357
+ });
358
+ } catch (error) {
359
+ return sourceError(kind, error, { repository: parsed.repository, locator: parsed.path });
360
+ }
361
+ }
362
+
363
+ export function collectTagObservation({
364
+ repoRoot,
365
+ tag,
366
+ expectedCommit,
367
+ execute = execFileSync,
368
+ } = {}) {
369
+ const kind = 'tag';
370
+ if (!repoRoot || !safeTag(tag) || !expectedCommit) return failure(kind, 'unproven', 'PROVENANCE_TAG_INPUT_INVALID');
371
+ const tagRef = `refs/tags/${tag}`;
372
+ try {
373
+ const commit = outputOf(executeFile(execute, 'git', [
374
+ 'rev-parse', '--verify', '--end-of-options', `${tagRef}^{commit}`,
375
+ ], repoRoot)).trim();
376
+ if (!/^[0-9a-f]{40}$/i.test(commit)) return failure(kind, 'unproven', 'PROVENANCE_TAG_UNRESOLVED', { tag });
377
+ if (commit !== expectedCommit) return mismatch(kind, 'PROVENANCE_COMMIT_MISMATCH', { tag, commit, expectedCommit });
378
+ return result(kind, 'verified', { tag, commit, expectedCommit, observed: { tag, commit } });
379
+ } catch (error) {
380
+ return failure(kind, 'unproven', errorCode(error), { tag });
381
+ }
382
+ }
383
+
384
+ function npmCommand() {
385
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
386
+ }
387
+
388
+ export function collectNpmObservation({
389
+ name,
390
+ version,
391
+ expectedIntegrity,
392
+ expectedCommit,
393
+ repository,
394
+ execute = execFileSync,
395
+ } = {}) {
396
+ const kind = 'npm';
397
+ if (!safePackageName(name) || !safeVersion(version)) return failure(kind, 'unproven', 'PROVENANCE_PACKAGE_INPUT_INVALID');
398
+ if (!expectedIntegrity) return failure(kind, 'unproven', 'PROVENANCE_INTEGRITY_MISSING', { name, version });
399
+ const expectedRepository = normalizeRepository(repository);
400
+ if (!expectedRepository || !/^[0-9a-f]{40}$/i.test(String(expectedCommit || ''))) {
401
+ return failure(kind, 'unproven', 'PROVENANCE_NPM_BINDING_MISSING', { name, version });
402
+ }
403
+ const cacheRoot = mkdtempSync(join(tmpdir(), 'wendkeep-npm-provenance-'));
404
+ try {
405
+ const raw = executeFile(execute, npmCommand(), [
406
+ 'view', `${name}@${version}`, 'name', 'version', 'dist.integrity', 'gitHead', 'repository',
407
+ '--json',
408
+ '--registry=https://registry.npmjs.org/',
409
+ '--cache', cacheRoot,
410
+ '--prefer-online',
411
+ '--fetch-retries=0',
412
+ ], undefined);
413
+ const response = parseJsonOutput(raw);
414
+ const observedIntegrity = typeof response === 'string'
415
+ ? response
416
+ : String(response?.dist?.integrity || response?.integrity || '');
417
+ const observedName = String(response?.name || '');
418
+ const observedVersion = String(response?.version || '');
419
+ const commit = String(response?.gitHead || response?.git_head || '').trim();
420
+ const observedRepository = normalizeRepository(response?.repository?.url || response?.repository || '');
421
+ if (observedName && observedName !== name) return mismatch(kind, 'PROVENANCE_PACKAGE_MISMATCH', { name, version, observedName });
422
+ if (observedVersion && observedVersion !== version) return mismatch(kind, 'PROVENANCE_VERSION_MISMATCH', { name, version, observedVersion });
423
+ if (!observedIntegrity) return failure(kind, 'reported', 'PROVENANCE_INTEGRITY_UNOBSERVED', { name, version });
424
+ if (observedIntegrity !== expectedIntegrity) return mismatch(kind, 'PROVENANCE_INTEGRITY_MISMATCH', { name, version, observedIntegrity, expectedIntegrity });
425
+ if (!commit || !observedRepository) return failure(kind, 'reported', 'PROVENANCE_NPM_BINDING_UNOBSERVED', {
426
+ name, version, integrity: observedIntegrity,
427
+ });
428
+ if (commit !== expectedCommit) return mismatch(kind, 'PROVENANCE_COMMIT_MISMATCH', {
429
+ name, version, commit, expectedCommit,
430
+ });
431
+ if (observedRepository !== expectedRepository) return mismatch(kind, 'PROVENANCE_REPOSITORY_MISMATCH', {
432
+ name, version, repository: expectedRepository, observedRepository,
433
+ });
434
+ return result(kind, 'verified', {
435
+ name,
436
+ version,
437
+ integrity: observedIntegrity,
438
+ expectedIntegrity,
439
+ commit,
440
+ repository: observedRepository,
441
+ status: 'published',
442
+ observed: {
443
+ name, version, integrity: observedIntegrity, commit, repository: observedRepository, status: 'published',
444
+ },
445
+ });
446
+ } catch (error) {
447
+ return sourceError(kind, error, { name, version });
448
+ } finally {
449
+ rmSync(cacheRoot, { recursive: true, force: true });
450
+ }
451
+ }
452
+
453
+ function githubTagCommit({ repository, tag, execute }) {
454
+ const refPath = `/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`;
455
+ const ref = parseJsonOutput(executeGithubApi(execute, refPath));
456
+ let object = ref?.object;
457
+ const visited = new Set();
458
+ for (let depth = 0; depth < 8; depth += 1) {
459
+ const type = String(object?.type || '').toLowerCase();
460
+ const sha = String(object?.sha || '').trim();
461
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
462
+ return { state: 'reported', code: 'PROVENANCE_TAG_COMMIT_UNOBSERVED' };
463
+ }
464
+ if (type === 'commit') return { state: 'verified', commit: sha };
465
+ if (type !== 'tag' || visited.has(sha)) {
466
+ return { state: 'reported', code: 'PROVENANCE_TAG_COMMIT_AMBIGUOUS' };
467
+ }
468
+ visited.add(sha);
469
+ const tagObject = parseJsonOutput(executeGithubApi(execute, `/repos/${repository}/git/tags/${sha}`));
470
+ object = tagObject?.object;
471
+ }
472
+ return { state: 'reported', code: 'PROVENANCE_TAG_COMMIT_AMBIGUOUS' };
473
+ }
474
+
475
+ export function collectGitHubReleaseObservation({
476
+ repository,
477
+ tag,
478
+ expectedCommit,
479
+ expectedVersion,
480
+ expectedNotes,
481
+ execute = execFileSync,
482
+ } = {}) {
483
+ const kind = 'github-release';
484
+ const normalized = normalizeRepository(repository);
485
+ if (!normalized || !safeTag(tag) || !expectedCommit) return failure(kind, 'unproven', 'PROVENANCE_RELEASE_INPUT_INVALID');
486
+ try {
487
+ const path = `/repos/${normalized}/releases/tags/${encodeURIComponent(tag)}`;
488
+ const response = parseJsonOutput(executeGithubApi(execute, path));
489
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
490
+ return failure(kind, 'reported', 'PROVENANCE_SOURCE_UNAVAILABLE', { repository: normalized, tag });
491
+ }
492
+ const observedRepository = normalizeRepository(response.repository?.full_name || normalized);
493
+ if (observedRepository && observedRepository !== normalized) return mismatch(kind, 'PROVENANCE_REPOSITORY_MISMATCH', { repository: normalized, observedRepository, tag });
494
+ const observedTag = String(response.tag_name || '');
495
+ const version = String(expectedVersion || String(tag).replace(/^v/i, ''));
496
+ const observedVersion = observedTag.replace(/^v/i, '');
497
+ const reasonCodes = [];
498
+ if (!observedTag || observedTag !== tag) reasonCodes.push('PROVENANCE_TAG_MISMATCH');
499
+ if (observedVersion !== version) reasonCodes.push('PROVENANCE_VERSION_MISMATCH');
500
+ const targetCommitish = String(response.target_commitish || '').trim();
501
+ const explicitCommit = String(response.target_sha || response.commit || '').trim();
502
+ if (/^[0-9a-f]{40}$/i.test(targetCommitish) && targetCommitish !== expectedCommit) {
503
+ reasonCodes.push('PROVENANCE_COMMIT_MISMATCH');
504
+ }
505
+ if (explicitCommit && explicitCommit !== expectedCommit) reasonCodes.push('PROVENANCE_COMMIT_MISMATCH');
506
+ if (expectedNotes !== undefined && String(response.body || '').trim() !== String(expectedNotes).trim()) {
507
+ reasonCodes.push('PROVENANCE_NOTES_MISMATCH');
508
+ }
509
+ if (reasonCodes.some((code) => code.endsWith('_MISMATCH'))) {
510
+ return failure(kind, 'conflict', reasonCodes[0], {
511
+ reasonCodes,
512
+ diagnostics: reasonCodes.map((code) => ({ code })),
513
+ repository: normalized,
514
+ tag,
515
+ version,
516
+ commit: explicitCommit,
517
+ });
518
+ }
519
+ if (response.draft === true) return failure(kind, 'reported', 'PROVENANCE_RELEASE_DRAFT', {
520
+ repository: normalized, tag, version, target_commitish: targetCommitish,
521
+ });
522
+ const resolvedTag = githubTagCommit({ repository: normalized, tag, execute });
523
+ if (resolvedTag.state !== 'verified') return failure(kind, 'reported', resolvedTag.code, {
524
+ repository: normalized, tag, version, target_commitish: targetCommitish,
525
+ });
526
+ const commit = resolvedTag.commit;
527
+ if (commit !== expectedCommit) return mismatch(kind, 'PROVENANCE_COMMIT_MISMATCH', {
528
+ repository: normalized, tag, version, commit, expectedCommit, target_commitish: targetCommitish,
529
+ });
530
+ return result(kind, 'verified', {
531
+ repository: normalized,
532
+ tag,
533
+ version,
534
+ commit,
535
+ target_commitish: targetCommitish,
536
+ status: 'published',
537
+ notes: String(response.body || '').trim(),
538
+ observed: {
539
+ repository: normalized, tag, version, commit, target_commitish: targetCommitish, status: 'published',
540
+ },
541
+ });
542
+ } catch (error) {
543
+ return sourceError(kind, error, { repository: normalized, tag });
544
+ }
545
+ }
546
+
547
+ export { normalizeRepository };