@ontrails/trails 0.2.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,1780 @@
1
+ /* eslint-disable no-use-before-define -- release policy keeps exported entrypoints before the local CLI/GitHub helpers. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ import {
6
+ classifyPackageRegistryState,
7
+ discoverRegistryWorkspaces,
8
+ } from './native-bun-registry.js';
9
+ import type {
10
+ PackageRegistryFacts,
11
+ RegistryResult,
12
+ } from './native-bun-registry.js';
13
+ import { compareSemver, parseSemver } from './semver.js';
14
+ import { isInitialZeroLineSourceTransition } from './zero-line-transition.js';
15
+
16
+ export type PublishIntent =
17
+ | 'publish:auto'
18
+ | 'publish:block'
19
+ | 'publish:manual'
20
+ | 'publish:none';
21
+ export type ChannelIntent = 'channel:beta' | 'channel:stable';
22
+ export type ReleaseIntent = 'release:major' | 'release:minor' | 'release:patch';
23
+ export type ReleasePolicyDecision = 'auto' | 'block' | 'manual' | 'none';
24
+ export type StackIntent = 'stack:boundary';
25
+
26
+ export interface ReleasePolicyChangedFile {
27
+ readonly path: string;
28
+ readonly status?: string;
29
+ }
30
+
31
+ export interface ReleasePolicyCommit {
32
+ readonly authorEmail: string;
33
+ readonly authorName: string;
34
+ readonly committerEmail: string;
35
+ readonly committerName: string;
36
+ readonly subject: string;
37
+ }
38
+
39
+ export interface ReleasePolicyPullRequest {
40
+ readonly baseRefName: string;
41
+ readonly body: string;
42
+ readonly comments: readonly string[];
43
+ readonly headRefName: string;
44
+ readonly headSha?: string | undefined;
45
+ readonly labels: readonly string[];
46
+ readonly number: number;
47
+ readonly title: string;
48
+ readonly userLogin: string;
49
+ }
50
+
51
+ export interface ReleasePolicySourcePullRequest {
52
+ readonly commitShas: readonly string[];
53
+ readonly hasChangeset: boolean;
54
+ readonly labels: readonly string[];
55
+ readonly mergeCommitSha?: string | undefined;
56
+ readonly mergedAt?: string | null | undefined;
57
+ readonly number: number;
58
+ readonly title: string;
59
+ readonly trustedStackEvidence?: string | undefined;
60
+ }
61
+
62
+ export interface ReleasePolicyRegistryPackage {
63
+ readonly error?: string | undefined;
64
+ readonly expectedTagVersion?: string | undefined;
65
+ readonly name: string;
66
+ readonly status: 'inaccessible' | 'missing' | 'published';
67
+ readonly version: string;
68
+ readonly versionPublished?: boolean | undefined;
69
+ }
70
+
71
+ export type ReleasePolicyCiProofSource =
72
+ | 'assumed'
73
+ | 'exact-sha'
74
+ | 'missing'
75
+ | 'release-pr-head';
76
+
77
+ export interface ReleasePolicyCiProof {
78
+ readonly passed: boolean;
79
+ readonly source: ReleasePolicyCiProofSource;
80
+ readonly summary: string;
81
+ }
82
+
83
+ export interface ReleasePolicyCheckRun {
84
+ readonly check_suite?: { readonly app?: { readonly slug?: string } };
85
+ readonly conclusion?: string | null;
86
+ readonly name: string;
87
+ readonly status: string;
88
+ }
89
+
90
+ export interface ReleasePolicyInput {
91
+ readonly changedFiles: readonly ReleasePolicyChangedFile[];
92
+ readonly ciPassed?: boolean | undefined;
93
+ readonly ciProof?: ReleasePolicyCiProof | undefined;
94
+ readonly commit: ReleasePolicyCommit;
95
+ readonly distTag: string;
96
+ readonly previousVersion?: string | undefined;
97
+ readonly ref: string;
98
+ readonly registryPackages: readonly ReleasePolicyRegistryPackage[];
99
+ readonly releasePullRequest?: ReleasePolicyPullRequest | undefined;
100
+ readonly repository: string;
101
+ readonly sha: string;
102
+ readonly sourcePullRequests?: readonly ReleasePolicySourcePullRequest[];
103
+ readonly version: string;
104
+ }
105
+
106
+ export interface ReleasePolicyReport {
107
+ readonly autoEligible: boolean;
108
+ readonly blockers: readonly string[];
109
+ readonly channel: ChannelIntent | undefined;
110
+ readonly ciProof?: ReleasePolicyCiProof | undefined;
111
+ readonly createGitHubRelease: boolean;
112
+ readonly decision: ReleasePolicyDecision;
113
+ readonly diagnostics: readonly string[];
114
+ readonly publish: PublishIntent | undefined;
115
+ readonly reasons: readonly string[];
116
+ readonly release: ReleaseIntent | undefined;
117
+ readonly shouldPublish: boolean;
118
+ readonly stack: StackIntent | undefined;
119
+ }
120
+
121
+ interface FamilyResult<T extends string> {
122
+ readonly conflicts: readonly string[];
123
+ readonly unknown: readonly string[];
124
+ readonly value?: T;
125
+ }
126
+
127
+ interface ReleasePolicyCiProofTarget {
128
+ readonly sha: string;
129
+ readonly source: Exclude<ReleasePolicyCiProofSource, 'assumed' | 'missing'>;
130
+ readonly summary: string;
131
+ }
132
+
133
+ const repoRoot = resolve(process.cwd());
134
+ const publishIntents = new Set<PublishIntent>([
135
+ 'publish:auto',
136
+ 'publish:block',
137
+ 'publish:manual',
138
+ 'publish:none',
139
+ ]);
140
+ const channelIntents = new Set<ChannelIntent>([
141
+ 'channel:beta',
142
+ 'channel:stable',
143
+ ]);
144
+ const releaseIntents = new Set<ReleaseIntent>([
145
+ 'release:major',
146
+ 'release:minor',
147
+ 'release:patch',
148
+ ]);
149
+ const stackIntents = new Set<StackIntent>(['stack:boundary']);
150
+
151
+ export const channelIntentForDistTag = (
152
+ distTag: string
153
+ ): ChannelIntent | undefined => {
154
+ if (distTag === 'beta') {
155
+ return 'channel:beta';
156
+ }
157
+ if (distTag === 'latest') {
158
+ return 'channel:stable';
159
+ }
160
+ return undefined;
161
+ };
162
+
163
+ export const evaluateReleasePolicy = (
164
+ input: ReleasePolicyInput
165
+ ): ReleasePolicyReport => {
166
+ const labels = input.releasePullRequest?.labels ?? [];
167
+ const publish = readLabelFamily(labels, 'publish:', publishIntents);
168
+ const channel = readLabelFamily(labels, 'channel:', channelIntents);
169
+ const release = readLabelFamily(labels, 'release:', releaseIntents);
170
+ const stack = readSourceStackLabels(input.sourcePullRequests);
171
+ const blockers = [
172
+ ...publish.conflicts,
173
+ ...channel.conflicts,
174
+ ...release.conflicts,
175
+ ...stack.conflicts,
176
+ ...publish.unknown,
177
+ ...channel.unknown,
178
+ ...release.unknown,
179
+ ...stack.unknown,
180
+ ...registryBlockers(input.registryPackages, input.distTag),
181
+ ];
182
+ const diagnostics: string[] = [];
183
+ const reasons: string[] = [];
184
+
185
+ if (publish.value === 'publish:block') {
186
+ blockers.push('publish:block is set');
187
+ }
188
+
189
+ if (blockers.length > 0) {
190
+ return makeReport(input, {
191
+ blockers,
192
+ channel: channel.value,
193
+ decision: 'block',
194
+ diagnostics,
195
+ publish: publish.value,
196
+ reasons,
197
+ release: release.value,
198
+ stack: stack.value,
199
+ });
200
+ }
201
+
202
+ if (publish.value === 'publish:none') {
203
+ if (!hasPublishNoneReason(input.releasePullRequest)) {
204
+ return makeReport(input, {
205
+ blockers: [
206
+ 'publish:none requires an audit reason in the release PR body or comments',
207
+ ],
208
+ channel: channel.value,
209
+ decision: 'block',
210
+ diagnostics,
211
+ publish: publish.value,
212
+ reasons,
213
+ release: release.value,
214
+ stack: stack.value,
215
+ });
216
+ }
217
+
218
+ reasons.push(
219
+ 'publish:none is set, so npm publish and GitHub release creation are skipped'
220
+ );
221
+ return makeReport(input, {
222
+ blockers,
223
+ channel: channel.value,
224
+ decision: 'none',
225
+ diagnostics,
226
+ publish: publish.value,
227
+ reasons,
228
+ release: release.value,
229
+ stack: stack.value,
230
+ });
231
+ }
232
+
233
+ if (!publish.value) {
234
+ reasons.push('No publish:* label is set; routing to manual approval');
235
+ return makeReport(input, {
236
+ blockers,
237
+ channel: channel.value,
238
+ decision: 'manual',
239
+ diagnostics,
240
+ reasons,
241
+ release: release.value,
242
+ stack: stack.value,
243
+ });
244
+ }
245
+
246
+ if (publish.value === 'publish:manual') {
247
+ reasons.push('publish:manual is set');
248
+ return makeReport(input, {
249
+ blockers,
250
+ channel: channel.value,
251
+ decision: 'manual',
252
+ diagnostics,
253
+ publish: publish.value,
254
+ reasons,
255
+ release: release.value,
256
+ stack: stack.value,
257
+ });
258
+ }
259
+
260
+ const autoChecks = evaluateAutoChecks(
261
+ input,
262
+ channel.value,
263
+ release.value,
264
+ stack.value
265
+ );
266
+ diagnostics.push(...autoChecks.diagnostics);
267
+
268
+ if (!autoChecks.ok) {
269
+ reasons.push(
270
+ 'publish:auto requested, but one or more low-risk checks failed; routing to manual approval'
271
+ );
272
+ return makeReport(input, {
273
+ blockers,
274
+ channel: channel.value,
275
+ decision: 'manual',
276
+ diagnostics,
277
+ publish: publish.value,
278
+ reasons,
279
+ release: release.value,
280
+ stack: stack.value,
281
+ });
282
+ }
283
+
284
+ const proof = readCiProofFromInput(input);
285
+ if (proof) {
286
+ reasons.push(`${proof.summary} passed`);
287
+ }
288
+ reasons.push(
289
+ 'publish:auto is set and low-risk release checks passed for the generated release'
290
+ );
291
+ return makeReport(input, {
292
+ autoEligible: true,
293
+ blockers,
294
+ channel: channel.value,
295
+ decision: 'auto',
296
+ diagnostics,
297
+ publish: publish.value,
298
+ reasons,
299
+ release: release.value,
300
+ stack: stack.value,
301
+ });
302
+ };
303
+
304
+ export const labelsForReleasePullRequest = ({
305
+ currentVersion,
306
+ existingLabels,
307
+ nextDistTag,
308
+ nextVersion,
309
+ sourcePullRequests,
310
+ }: {
311
+ readonly currentVersion: string;
312
+ readonly existingLabels: readonly string[];
313
+ readonly nextDistTag: string;
314
+ readonly nextVersion: string;
315
+ readonly sourcePullRequests?: readonly ReleasePolicySourcePullRequest[];
316
+ }): readonly string[] => {
317
+ const labels = new Set(existingLabels);
318
+ const labelsToAdd: string[] = [];
319
+ const stack = readSourceStackLabels(sourcePullRequests);
320
+ const stackDiagnostics = [
321
+ ...stack.conflicts,
322
+ ...stack.unknown,
323
+ ...evaluateSourceStackEvidence(sourcePullRequests, stack.value),
324
+ ];
325
+ const expectedChannel = channelIntentForDistTag(nextDistTag);
326
+ const expectedRelease = releaseIntentForVersionDelta(
327
+ currentVersion,
328
+ nextVersion
329
+ );
330
+
331
+ if (!hasLabelFamily(labels, 'publish:')) {
332
+ labelsToAdd.push(
333
+ stackDiagnostics.length === 0 && expectedChannel && expectedRelease
334
+ ? 'publish:auto'
335
+ : 'publish:manual'
336
+ );
337
+ }
338
+
339
+ if (expectedChannel && !hasLabelFamily(labels, 'channel:')) {
340
+ labelsToAdd.push(expectedChannel);
341
+ }
342
+
343
+ if (expectedRelease && !hasLabelFamily(labels, 'release:')) {
344
+ labelsToAdd.push(expectedRelease);
345
+ }
346
+
347
+ return labelsToAdd;
348
+ };
349
+
350
+ export const releasePolicyRequiresCiProof = (
351
+ input: ReleasePolicyInput
352
+ ): boolean => {
353
+ const publish = readLabelFamily(
354
+ input.releasePullRequest?.labels ?? [],
355
+ 'publish:',
356
+ publishIntents
357
+ );
358
+ return (
359
+ publish.value === 'publish:auto' &&
360
+ publish.conflicts.length === 0 &&
361
+ publish.unknown.length === 0
362
+ );
363
+ };
364
+
365
+ export const selectReleasePolicyCiProofTarget = ({
366
+ releasePullRequest,
367
+ releasePullRequestHeadTreeSha,
368
+ sha,
369
+ shaTreeSha,
370
+ }: {
371
+ readonly releasePullRequest?: ReleasePolicyPullRequest | undefined;
372
+ readonly releasePullRequestHeadTreeSha?: string | undefined;
373
+ readonly sha: string;
374
+ readonly shaTreeSha?: string | undefined;
375
+ }): ReleasePolicyCiProofTarget => {
376
+ if (
377
+ releasePullRequest?.headSha &&
378
+ shaTreeSha &&
379
+ releasePullRequestHeadTreeSha &&
380
+ shaTreeSha === releasePullRequestHeadTreeSha
381
+ ) {
382
+ return {
383
+ sha: releasePullRequest.headSha,
384
+ source: 'release-pr-head',
385
+ summary: 'Generated release PR head CI proof',
386
+ };
387
+ }
388
+
389
+ return {
390
+ sha,
391
+ source: 'exact-sha',
392
+ summary: 'Exact-SHA CI proof',
393
+ };
394
+ };
395
+
396
+ const makeReport = (
397
+ input: ReleasePolicyInput,
398
+ options: {
399
+ readonly autoEligible?: boolean;
400
+ readonly blockers: readonly string[];
401
+ readonly channel: ChannelIntent | undefined;
402
+ readonly decision: ReleasePolicyDecision;
403
+ readonly diagnostics: readonly string[];
404
+ readonly publish?: PublishIntent | undefined;
405
+ readonly reasons: readonly string[];
406
+ readonly release: ReleaseIntent | undefined;
407
+ readonly stack: StackIntent | undefined;
408
+ }
409
+ ): ReleasePolicyReport => {
410
+ const canPublish =
411
+ input.releasePullRequest !== undefined &&
412
+ (options.decision === 'auto' || options.decision === 'manual');
413
+ const packagesPublished = registryComplete(
414
+ input.registryPackages,
415
+ input.distTag
416
+ );
417
+ const reasons = [...options.reasons];
418
+ if (canPublish) {
419
+ reasons.push(
420
+ packagesPublished
421
+ ? 'Registry package state already matches this release; npm publish will be skipped'
422
+ : 'Registry package state is incomplete for this release; npm publish remains required'
423
+ );
424
+ }
425
+ return {
426
+ autoEligible: options.autoEligible ?? false,
427
+ blockers: options.blockers,
428
+ channel: options.channel,
429
+ ciProof: readCiProofFromInput(input),
430
+ createGitHubRelease: canPublish,
431
+ decision: options.decision,
432
+ diagnostics: options.diagnostics,
433
+ publish: options.publish,
434
+ reasons,
435
+ release: options.release,
436
+ shouldPublish: canPublish && !packagesPublished,
437
+ stack: options.stack,
438
+ };
439
+ };
440
+
441
+ const readLabelFamily = <T extends string>(
442
+ labels: readonly string[],
443
+ prefix: string,
444
+ allowed: ReadonlySet<T>
445
+ ): FamilyResult<T> => {
446
+ const values = labels.filter((label) => label.startsWith(prefix));
447
+ const known = values.filter((label): label is T => allowed.has(label as T));
448
+ const unknown = values.filter((label) => !allowed.has(label as T));
449
+
450
+ if (known.length > 1) {
451
+ return {
452
+ conflicts: [`Conflicting ${prefix} labels: ${known.join(', ')}`],
453
+ unknown: unknown.map((label) => `Unknown ${prefix} label: ${label}`),
454
+ };
455
+ }
456
+
457
+ const result: { conflicts: string[]; unknown: string[]; value?: T } = {
458
+ conflicts: [],
459
+ unknown: unknown.map((label) => `Unknown ${prefix} label: ${label}`),
460
+ };
461
+ const [value] = known;
462
+ if (value) {
463
+ result.value = value;
464
+ }
465
+ return result;
466
+ };
467
+
468
+ const factsFromPolicyPackage = (
469
+ entry: ReleasePolicyRegistryPackage,
470
+ distTag: string
471
+ ): PackageRegistryFacts => ({
472
+ error: entry.error,
473
+ expectedTag: distTag,
474
+ expectedTagVersion: entry.expectedTagVersion,
475
+ name: entry.name,
476
+ status: entry.status,
477
+ targetVersion: entry.version,
478
+ versionPublished: entry.versionPublished,
479
+ });
480
+
481
+ const registryComplete = (
482
+ packages: readonly ReleasePolicyRegistryPackage[],
483
+ distTag: string
484
+ ): boolean =>
485
+ packages.length > 0 &&
486
+ packages.every(
487
+ (entry) =>
488
+ classifyPackageRegistryState(factsFromPolicyPackage(entry, distTag))
489
+ .kind === 'complete'
490
+ );
491
+
492
+ const registryBlockers = (
493
+ packages: readonly ReleasePolicyRegistryPackage[],
494
+ distTag: string
495
+ ): readonly string[] =>
496
+ packages.flatMap((entry) => {
497
+ const state = classifyPackageRegistryState(
498
+ factsFromPolicyPackage(entry, distTag)
499
+ );
500
+ if (state.kind === 'registry-inaccessible') {
501
+ return [`${entry.name}: registry state is inaccessible: ${state.error}`];
502
+ }
503
+ if (state.kind === 'tag-points-ahead') {
504
+ return [
505
+ `${entry.name}: dist-tag ${distTag} points to ${state.currentTagVersion}, expected ${entry.version}`,
506
+ ];
507
+ }
508
+ if (state.kind === 'needs-tag-repair') {
509
+ return [
510
+ `${entry.name}: version ${entry.version} is already published but dist-tag ${distTag} points to ${state.currentTagVersion ?? '(missing)'}`,
511
+ ];
512
+ }
513
+ return [];
514
+ });
515
+
516
+ const evaluateAutoChecks = (
517
+ input: ReleasePolicyInput,
518
+ channel: ChannelIntent | undefined,
519
+ release: ReleaseIntent | undefined,
520
+ stack: StackIntent | undefined
521
+ ) => {
522
+ const diagnostics: string[] = [];
523
+ const generatedDiff = evaluateGeneratedReleaseDiff(input.changedFiles);
524
+ diagnostics.push(...generatedDiff.diagnostics);
525
+ diagnostics.push(
526
+ ...evaluateSourceStackEvidence(input.sourcePullRequests, stack)
527
+ );
528
+
529
+ if (input.repository !== 'outfitter-dev/trails') {
530
+ diagnostics.push(
531
+ `Expected repository outfitter-dev/trails, found ${input.repository}`
532
+ );
533
+ }
534
+
535
+ if (input.ref !== 'refs/heads/main') {
536
+ diagnostics.push(`Expected refs/heads/main, found ${input.ref}`);
537
+ }
538
+
539
+ const expectedChannel = channelIntentForDistTag(input.distTag);
540
+ if (!expectedChannel) {
541
+ diagnostics.push(
542
+ `Unsupported release dist-tag for automation: ${input.distTag}`
543
+ );
544
+ } else if (channel !== expectedChannel) {
545
+ diagnostics.push(
546
+ `Expected ${expectedChannel} for npm dist-tag ${input.distTag}, found ${channel ?? 'no channel:* label'}`
547
+ );
548
+ }
549
+
550
+ if (input.releasePullRequest) {
551
+ if (input.releasePullRequest.headRefName !== 'changeset-release/main') {
552
+ diagnostics.push(
553
+ `Expected release PR head changeset-release/main, found ${input.releasePullRequest.headRefName}`
554
+ );
555
+ }
556
+ if (input.releasePullRequest.baseRefName !== 'main') {
557
+ diagnostics.push(
558
+ `Expected release PR base main, found ${input.releasePullRequest.baseRefName}`
559
+ );
560
+ }
561
+ if (input.releasePullRequest.title !== 'chore(release): version packages') {
562
+ diagnostics.push(
563
+ `Expected release PR title "chore(release): version packages", found "${input.releasePullRequest.title}"`
564
+ );
565
+ }
566
+ if (input.releasePullRequest.userLogin !== 'github-actions[bot]') {
567
+ diagnostics.push(
568
+ `Expected release PR author github-actions[bot], found ${input.releasePullRequest.userLogin}`
569
+ );
570
+ }
571
+ } else {
572
+ diagnostics.push(
573
+ 'Could not resolve the release pull request for the current commit'
574
+ );
575
+ }
576
+
577
+ if (
578
+ !/^chore\(release\): version packages \(#\d+\)$/u.test(input.commit.subject)
579
+ ) {
580
+ diagnostics.push(
581
+ `Expected squash commit subject chore(release): version packages (#<pr>), found "${input.commit.subject}"`
582
+ );
583
+ }
584
+
585
+ if (
586
+ !isGitHubActionsIdentity(input.commit.authorName, input.commit.authorEmail)
587
+ ) {
588
+ diagnostics.push(
589
+ `Expected GitHub Actions bot author, found ${input.commit.authorName} <${input.commit.authorEmail}>`
590
+ );
591
+ }
592
+
593
+ if (
594
+ !isGitHubCommitter(input.commit.committerName, input.commit.committerEmail)
595
+ ) {
596
+ diagnostics.push(
597
+ `Expected GitHub committer, found ${input.commit.committerName} <${input.commit.committerEmail}>`
598
+ );
599
+ }
600
+
601
+ const expectedRelease = input.previousVersion
602
+ ? releaseIntentForVersionDelta(input.previousVersion, input.version)
603
+ : undefined;
604
+ if (!input.previousVersion) {
605
+ diagnostics.push(
606
+ 'Could not read previous @ontrails/trails package version'
607
+ );
608
+ } else if (!expectedRelease) {
609
+ diagnostics.push(
610
+ `Expected ${input.version} to be newer than previous version ${input.previousVersion}`
611
+ );
612
+ } else if (release && expectedRelease !== release) {
613
+ diagnostics.push(
614
+ `${release} is set, but ${input.previousVersion} -> ${input.version} looks like ${expectedRelease}`
615
+ );
616
+ }
617
+
618
+ diagnostics.push(...evaluateCiProofEvidence(input));
619
+
620
+ return { diagnostics, ok: diagnostics.length === 0 };
621
+ };
622
+
623
+ const evaluateCiProofEvidence = (
624
+ input: ReleasePolicyInput
625
+ ): readonly string[] => {
626
+ const proof = readCiProofFromInput(input);
627
+ if (proof?.passed) {
628
+ return [];
629
+ }
630
+ return [
631
+ proof
632
+ ? `${proof.summary} has not passed`
633
+ : 'CI proof has not been evaluated',
634
+ ];
635
+ };
636
+
637
+ const readCiProofFromInput = (
638
+ input: ReleasePolicyInput
639
+ ): ReleasePolicyCiProof | undefined => {
640
+ if (input.ciProof) {
641
+ return input.ciProof;
642
+ }
643
+ if (input.ciPassed === undefined) {
644
+ return undefined;
645
+ }
646
+ return {
647
+ passed: input.ciPassed,
648
+ source: 'exact-sha',
649
+ summary: 'Exact-SHA CI proof',
650
+ };
651
+ };
652
+
653
+ const readSourceStackLabels = (
654
+ sourcePullRequests: readonly ReleasePolicySourcePullRequest[] | undefined
655
+ ) => {
656
+ if (!sourcePullRequests) {
657
+ return { conflicts: [], unknown: [] };
658
+ }
659
+ const labels = sourcePullRequests.flatMap((pull) =>
660
+ pull.labels
661
+ .filter((label) => label.startsWith('stack:'))
662
+ .map((label) => `${label} on #${pull.number}`)
663
+ );
664
+ const normalized = [
665
+ ...new Set(labels.map((label) => label.split(' on #', 1)[0] ?? label)),
666
+ ];
667
+ const result = readLabelFamily(normalized, 'stack:', stackIntents);
668
+
669
+ return {
670
+ conflicts: result.conflicts.map(
671
+ (conflict) => `${conflict} across source PRs`
672
+ ),
673
+ unknown: result.unknown.map((unknown) => {
674
+ const label = unknown.replace('Unknown stack: label: ', '');
675
+ const source = labels.find((candidate) => candidate.startsWith(label));
676
+ return source ? `Unknown stack: label: ${source}` : unknown;
677
+ }),
678
+ value: result.value,
679
+ };
680
+ };
681
+
682
+ const evaluateSourceStackEvidence = (
683
+ sourcePullRequests: readonly ReleasePolicySourcePullRequest[] | undefined,
684
+ stack: StackIntent | undefined
685
+ ): readonly string[] => {
686
+ if (!sourcePullRequests) {
687
+ return [
688
+ 'Could not resolve source PR stack evidence for the generated release',
689
+ ];
690
+ }
691
+
692
+ const changesetPulls = sourcePullRequests.filter((pull) => pull.hasChangeset);
693
+ if (changesetPulls.length === 0) {
694
+ return [
695
+ 'Could not find a source PR that introduced a consumed .changeset/*.md file',
696
+ ];
697
+ }
698
+
699
+ const missingEvidence = changesetPulls.filter(
700
+ (pull) => !hasSourceStackEvidence(pull)
701
+ );
702
+ if (
703
+ (stack !== 'stack:boundary' &&
704
+ !changesetPulls.every((pull) => pull.trustedStackEvidence)) ||
705
+ missingEvidence.length > 0
706
+ ) {
707
+ return [
708
+ `publish:auto requires stack:boundary or trusted Graphite merge evidence on every changeset source PR in the release range; missing: ${missingEvidence
709
+ .map((pull) => `#${pull.number}`)
710
+ .join(', ')}`,
711
+ ];
712
+ }
713
+
714
+ return [];
715
+ };
716
+
717
+ const hasSourceStackEvidence = (
718
+ pull: ReleasePolicySourcePullRequest
719
+ ): boolean =>
720
+ pull.labels.includes('stack:boundary') ||
721
+ pull.trustedStackEvidence !== undefined;
722
+
723
+ const evaluateGeneratedReleaseDiff = (
724
+ changedFiles: readonly ReleasePolicyChangedFile[]
725
+ ) => {
726
+ const diagnostics: string[] = [];
727
+ let hasChangesetDeletion = false;
728
+ let hasPackageVersionFile = false;
729
+
730
+ for (const file of changedFiles) {
731
+ if (file.path === '.changeset/pre.json' && file.status === 'M') {
732
+ continue;
733
+ }
734
+
735
+ if (/^\.changeset\/[^/]+\.md$/u.test(file.path) && file.status === 'D') {
736
+ hasChangesetDeletion = true;
737
+ continue;
738
+ }
739
+
740
+ if (
741
+ /^packages\/[^/]+\/(?:CHANGELOG\.md|package\.json)$/u.test(file.path) &&
742
+ file.status === 'M'
743
+ ) {
744
+ hasPackageVersionFile = true;
745
+ continue;
746
+ }
747
+
748
+ if (
749
+ /^adapters\/[^/]+\/(?:CHANGELOG\.md|package\.json)$/u.test(file.path) &&
750
+ file.status === 'M'
751
+ ) {
752
+ hasPackageVersionFile = true;
753
+ continue;
754
+ }
755
+
756
+ if (
757
+ /^apps\/trails\/(?:CHANGELOG\.md|package\.json)$/u.test(file.path) &&
758
+ file.status === 'M'
759
+ ) {
760
+ hasPackageVersionFile = true;
761
+ continue;
762
+ }
763
+
764
+ diagnostics.push(
765
+ `Unexpected release diff entry: ${file.status ?? '?'} ${file.path}`
766
+ );
767
+ }
768
+
769
+ if (!hasPackageVersionFile) {
770
+ diagnostics.push(
771
+ 'Generated release diff did not modify package.json or CHANGELOG.md files'
772
+ );
773
+ }
774
+ if (!hasChangesetDeletion) {
775
+ diagnostics.push(
776
+ 'Generated release diff did not delete a consumed .changeset/*.md file'
777
+ );
778
+ }
779
+
780
+ return { diagnostics, ok: diagnostics.length === 0 };
781
+ };
782
+
783
+ const hasPublishNoneReason = (
784
+ pr: ReleasePolicyPullRequest | undefined
785
+ ): boolean => {
786
+ if (!pr) {
787
+ return false;
788
+ }
789
+ const texts = [pr.body, ...pr.comments].map((text) => text.toLowerCase());
790
+ return texts.some(
791
+ (text) =>
792
+ text.includes('publish:none') &&
793
+ /(because|intentional|reason|skip|no publish)/u.test(text)
794
+ );
795
+ };
796
+
797
+ const hasLabelFamily = (
798
+ labels: ReadonlySet<string>,
799
+ prefix: string
800
+ ): boolean => {
801
+ for (const label of labels) {
802
+ if (label.startsWith(prefix)) {
803
+ return true;
804
+ }
805
+ }
806
+ return false;
807
+ };
808
+
809
+ const isGitHubActionsIdentity = (name: string, email: string): boolean =>
810
+ name === 'github-actions[bot]' &&
811
+ email === '41898282+github-actions[bot]@users.noreply.github.com';
812
+
813
+ const isGitHubCommitter = (name: string, email: string): boolean =>
814
+ name === 'GitHub' && email === 'noreply@github.com';
815
+
816
+ export const releaseIntentForVersionDelta = (
817
+ previousVersion: string,
818
+ nextVersion: string
819
+ ): ReleaseIntent | undefined => {
820
+ const previous = parseSemver(previousVersion);
821
+ const next = parseSemver(nextVersion);
822
+ if (!previous || !next) {
823
+ return undefined;
824
+ }
825
+ if (compareSemver(nextVersion, previousVersion) <= 0) {
826
+ return undefined;
827
+ }
828
+ if (next.major !== previous.major) {
829
+ return 'release:major';
830
+ }
831
+ if (next.minor !== previous.minor) {
832
+ return 'release:minor';
833
+ }
834
+ if (
835
+ next.patch !== previous.patch ||
836
+ next.prerelease !== previous.prerelease
837
+ ) {
838
+ return 'release:patch';
839
+ }
840
+ return undefined;
841
+ };
842
+
843
+ const writeGitHubOutput = async (
844
+ values: Record<string, boolean | number | string | undefined>
845
+ ): Promise<void> => {
846
+ const outputPath = process.env['GITHUB_OUTPUT'];
847
+ if (!outputPath) {
848
+ return;
849
+ }
850
+ const lines = Object.entries(values).map(
851
+ ([key, value]) => `${key}=${value ?? ''}`
852
+ );
853
+ await Bun.write(outputPath, `${lines.join('\n')}\n`);
854
+ };
855
+
856
+ const printReport = (report: ReleasePolicyReport): void => {
857
+ console.error(`trails: release policy decision is ${report.decision}`);
858
+ for (const reason of report.reasons) {
859
+ console.error(`trails: ${reason}`);
860
+ }
861
+ for (const diagnostic of report.diagnostics) {
862
+ console.error(`trails: policy diagnostic: ${diagnostic}`);
863
+ }
864
+ for (const blocker of report.blockers) {
865
+ console.error(`trails: policy blocker: ${blocker}`);
866
+ }
867
+ };
868
+
869
+ const runText = async (
870
+ command: readonly string[],
871
+ options: { readonly allowFailure?: boolean } = {}
872
+ ): Promise<string> => {
873
+ const subprocess = Bun.spawn([...command], {
874
+ cwd: repoRoot,
875
+ stderr: 'pipe',
876
+ stdout: 'pipe',
877
+ });
878
+ const [exitCode, stdout, stderr] = await Promise.all([
879
+ subprocess.exited,
880
+ new Response(subprocess.stdout).text(),
881
+ new Response(subprocess.stderr).text(),
882
+ ]);
883
+
884
+ if (exitCode !== 0) {
885
+ if (options.allowFailure) {
886
+ return '';
887
+ }
888
+ throw new Error(`${command.join(' ')} failed: ${stderr.trim()}`);
889
+ }
890
+
891
+ return stdout.trim();
892
+ };
893
+
894
+ const readCommitInfo = async (): Promise<ReleasePolicyCommit> => {
895
+ const output = await runText([
896
+ 'git',
897
+ 'log',
898
+ '-1',
899
+ '--format=%an%n%ae%n%cn%n%ce%n%s',
900
+ ]);
901
+ const [authorName, authorEmail, committerName, committerEmail, ...subject] =
902
+ output.split('\n');
903
+ if (!authorName || !authorEmail || !committerName || !committerEmail) {
904
+ throw new Error('Could not read current commit identity');
905
+ }
906
+ return {
907
+ authorEmail,
908
+ authorName,
909
+ committerEmail,
910
+ committerName,
911
+ subject: subject.join('\n'),
912
+ };
913
+ };
914
+
915
+ const readChangedFiles = async (): Promise<ReleasePolicyChangedFile[]> => {
916
+ const parentExists = await runText(
917
+ ['git', 'rev-parse', '--verify', 'HEAD^'],
918
+ {
919
+ allowFailure: true,
920
+ }
921
+ );
922
+ if (!parentExists) {
923
+ return [];
924
+ }
925
+ const output = await runText([
926
+ 'git',
927
+ 'diff',
928
+ '--name-status',
929
+ 'HEAD^',
930
+ 'HEAD',
931
+ ]);
932
+ if (!output) {
933
+ return [];
934
+ }
935
+ return output.split('\n').map((line) => {
936
+ const [status, path] = line.split(/\s+/, 2);
937
+ if (!status || !path) {
938
+ throw new Error(`Could not parse git diff entry: ${line}`);
939
+ }
940
+ return { path, status };
941
+ });
942
+ };
943
+
944
+ interface PackageJson {
945
+ readonly name?: string;
946
+ readonly version?: string;
947
+ }
948
+
949
+ const readTrailsPackageVersion = async (ref = 'HEAD'): Promise<string> => {
950
+ const raw =
951
+ ref === 'HEAD'
952
+ ? await readFile(join(repoRoot, 'apps', 'trails', 'package.json'), 'utf8')
953
+ : await runText(['git', 'show', `${ref}:apps/trails/package.json`], {
954
+ allowFailure: true,
955
+ });
956
+ if (!raw) {
957
+ return '';
958
+ }
959
+ const packageJson = JSON.parse(raw) as PackageJson;
960
+ return packageJson.version ?? '';
961
+ };
962
+
963
+ const readPreviousVersion = async (): Promise<string | undefined> => {
964
+ const version = await readTrailsPackageVersion('HEAD^');
965
+ return version || undefined;
966
+ };
967
+
968
+ const distTagForVersion = async (version: string): Promise<string> => {
969
+ if (!version.includes('-')) {
970
+ return 'latest';
971
+ }
972
+
973
+ const prePath = join(repoRoot, '.changeset', 'pre.json');
974
+ if (await Bun.file(prePath).exists()) {
975
+ const pre = (await Bun.file(prePath).json()) as {
976
+ readonly mode?: string;
977
+ readonly tag?: string;
978
+ };
979
+ if (pre.mode === 'pre' && pre.tag) {
980
+ return pre.tag;
981
+ }
982
+ }
983
+ return version.includes('-')
984
+ ? (version.split('-')[1]?.split('.')[0] ?? 'latest')
985
+ : 'latest';
986
+ };
987
+
988
+ const registryPackagesFromResults = (
989
+ results: readonly RegistryResult[]
990
+ ): readonly ReleasePolicyRegistryPackage[] =>
991
+ results.map((result) => {
992
+ if (result.status === 'published') {
993
+ return {
994
+ expectedTagVersion: result.expectedTagVersion,
995
+ name: result.name,
996
+ status: 'published',
997
+ version: result.workspaceVersion,
998
+ versionPublished: result.versionPublished,
999
+ };
1000
+ }
1001
+ return {
1002
+ ...(result.status === 'inaccessible' ? { error: result.error } : {}),
1003
+ name: result.name,
1004
+ status: result.status,
1005
+ version: result.workspaceVersion,
1006
+ versionPublished: false,
1007
+ };
1008
+ });
1009
+
1010
+ const readRegistryPackages = async (
1011
+ distTag: string
1012
+ ): Promise<readonly ReleasePolicyRegistryPackage[]> => {
1013
+ const { checkRegistryPosture, npmRegistryView, npmRegistryVersionProofView } =
1014
+ await import('./native-bun-registry.js');
1015
+ const workspaces = await discoverRegistryWorkspaces(repoRoot);
1016
+ const results = await checkRegistryPosture(
1017
+ workspaces,
1018
+ npmRegistryView,
1019
+ npmRegistryVersionProofView,
1020
+ distTag
1021
+ );
1022
+ return registryPackagesFromResults(results);
1023
+ };
1024
+
1025
+ const githubJson = async <T>(repository: string, path: string): Promise<T> => {
1026
+ const headers: Record<string, string> = {
1027
+ accept: 'application/vnd.github+json',
1028
+ 'x-github-api-version': '2022-11-28',
1029
+ };
1030
+ if (process.env['GITHUB_TOKEN']) {
1031
+ headers['authorization'] = `Bearer ${process.env['GITHUB_TOKEN']}`;
1032
+ }
1033
+ const response = await fetch(
1034
+ `https://api.github.com/repos/${repository}${path}`,
1035
+ {
1036
+ headers,
1037
+ }
1038
+ );
1039
+ if (!response.ok) {
1040
+ throw new Error(
1041
+ `GitHub API request failed for ${path}: ${response.status} ${response.statusText}`
1042
+ );
1043
+ }
1044
+ return (await response.json()) as T;
1045
+ };
1046
+
1047
+ interface GitHubPullRequest {
1048
+ readonly base: { readonly ref: string };
1049
+ readonly body?: string | null;
1050
+ readonly head: { readonly ref: string; readonly sha?: string };
1051
+ readonly labels: readonly { readonly name: string }[];
1052
+ readonly merge_commit_sha?: string | null;
1053
+ readonly merged_at?: string | null;
1054
+ readonly number: number;
1055
+ readonly title: string;
1056
+ readonly user: { readonly login: string };
1057
+ }
1058
+
1059
+ export interface ReleasePolicyGitHubComment {
1060
+ readonly author?: { readonly login?: string | null } | null;
1061
+ readonly body?: string | null;
1062
+ readonly user?: { readonly login?: string | null } | null;
1063
+ }
1064
+
1065
+ interface GitHubContentFile {
1066
+ readonly content: string;
1067
+ }
1068
+
1069
+ interface GitHubCommitResponse {
1070
+ readonly commit: { readonly tree?: { readonly sha?: string } };
1071
+ }
1072
+
1073
+ interface GitHubCheckRunsResponse {
1074
+ readonly check_runs: readonly ReleasePolicyCheckRun[];
1075
+ }
1076
+
1077
+ const managedGitHubLabels = [
1078
+ {
1079
+ color: '0e8a16',
1080
+ description:
1081
+ 'Generated release PR may publish automatically after policy checks pass.',
1082
+ name: 'publish:auto',
1083
+ },
1084
+ {
1085
+ color: 'fbca04',
1086
+ description:
1087
+ 'Generated release PR requires protected manual publish approval.',
1088
+ name: 'publish:manual',
1089
+ },
1090
+ {
1091
+ color: 'cfd3d7',
1092
+ description:
1093
+ 'Generated release PR intentionally skips npm and GitHub release publication.',
1094
+ name: 'publish:none',
1095
+ },
1096
+ {
1097
+ color: 'b60205',
1098
+ description: 'Generated release PR is blocked from publishing.',
1099
+ name: 'publish:block',
1100
+ },
1101
+ {
1102
+ color: '1d76db',
1103
+ description:
1104
+ 'Release publishes prerelease packages to the beta npm dist-tag.',
1105
+ name: 'channel:beta',
1106
+ },
1107
+ {
1108
+ color: '5319e7',
1109
+ description:
1110
+ 'Release publishes stable packages to the latest npm dist-tag.',
1111
+ name: 'channel:stable',
1112
+ },
1113
+ {
1114
+ color: 'c2e0c6',
1115
+ description:
1116
+ 'Release changes package versions by a patch-sized semver movement.',
1117
+ name: 'release:patch',
1118
+ },
1119
+ {
1120
+ color: 'bfdadc',
1121
+ description:
1122
+ 'Release changes package versions by a minor-sized semver movement.',
1123
+ name: 'release:minor',
1124
+ },
1125
+ {
1126
+ color: 'f9d0c4',
1127
+ description:
1128
+ 'Release changes package versions by a major-sized semver movement.',
1129
+ name: 'release:major',
1130
+ },
1131
+ {
1132
+ color: 'd4c5f9',
1133
+ description:
1134
+ 'Source PR is a stack boundary with complete release evidence.',
1135
+ name: 'stack:boundary',
1136
+ },
1137
+ ] as const;
1138
+
1139
+ export const selectGeneratedReleasePullRequest = <
1140
+ PullRequest extends {
1141
+ readonly base: { readonly ref: string };
1142
+ readonly head: { readonly ref: string };
1143
+ },
1144
+ >(
1145
+ pulls: readonly PullRequest[]
1146
+ ): PullRequest | undefined =>
1147
+ pulls.find(
1148
+ (candidate) =>
1149
+ candidate.base.ref === 'main' &&
1150
+ candidate.head.ref === 'changeset-release/main'
1151
+ );
1152
+
1153
+ /** Keep manual discovery limited to the approved initial version branch. */
1154
+ export const selectReleasePolicyPullRequest = <
1155
+ PullRequest extends {
1156
+ readonly base: { readonly ref: string };
1157
+ readonly head: { readonly ref: string };
1158
+ readonly labels: readonly { readonly name: string }[];
1159
+ },
1160
+ >(
1161
+ pulls: readonly PullRequest[],
1162
+ context: Pick<
1163
+ ReleasePolicyInput,
1164
+ 'previousVersion' | 'ref' | 'repository' | 'version'
1165
+ >
1166
+ ): PullRequest | undefined => {
1167
+ const generated = selectGeneratedReleasePullRequest(pulls);
1168
+ if (generated) {
1169
+ return generated;
1170
+ }
1171
+ if (
1172
+ context.repository !== 'outfitter-dev/trails' ||
1173
+ context.ref !== 'refs/heads/main' ||
1174
+ !isInitialZeroLineSourceTransition(
1175
+ context.previousVersion ?? '',
1176
+ context.version,
1177
+ '@ontrails/trails'
1178
+ )
1179
+ ) {
1180
+ return undefined;
1181
+ }
1182
+ return pulls.find((candidate) => {
1183
+ const labels = candidate.labels.map(({ name }) => name);
1184
+ return (
1185
+ candidate.base.ref === 'main' &&
1186
+ candidate.head.ref ===
1187
+ 'trl-1347-retarget-the-prepared-trails-package-family-to-010' &&
1188
+ labels.includes('publish:manual') &&
1189
+ labels.includes('channel:stable') &&
1190
+ labels.every(
1191
+ (label) =>
1192
+ !/^(?:publish|channel|release):/u.test(label) ||
1193
+ label === 'publish:manual' ||
1194
+ label === 'channel:stable'
1195
+ )
1196
+ );
1197
+ });
1198
+ };
1199
+
1200
+ const readReleasePullRequest = async (
1201
+ repository: string,
1202
+ sha: string,
1203
+ context: Pick<ReleasePolicyInput, 'previousVersion' | 'ref' | 'version'>
1204
+ ): Promise<ReleasePolicyPullRequest | undefined> => {
1205
+ const pulls = await githubJson<GitHubPullRequest[]>(
1206
+ repository,
1207
+ `/commits/${sha}/pulls`
1208
+ );
1209
+ const pull = selectReleasePolicyPullRequest(pulls, {
1210
+ ...context,
1211
+ repository,
1212
+ });
1213
+ if (!pull) {
1214
+ return undefined;
1215
+ }
1216
+ const comments = await githubJson<ReleasePolicyGitHubComment[]>(
1217
+ repository,
1218
+ `/issues/${pull.number}/comments`
1219
+ );
1220
+ return {
1221
+ baseRefName: pull.base.ref,
1222
+ body: pull.body ?? '',
1223
+ comments: comments.map((comment) => comment.body ?? ''),
1224
+ headRefName: pull.head.ref,
1225
+ ...(pull.head.sha === undefined ? {} : { headSha: pull.head.sha }),
1226
+ labels: pull.labels.map((label) => label.name),
1227
+ number: pull.number,
1228
+ title: pull.title,
1229
+ userLogin: pull.user.login,
1230
+ };
1231
+ };
1232
+
1233
+ const readOpenReleasePullRequest = async (
1234
+ repository: string
1235
+ ): Promise<ReleasePolicyPullRequest | undefined> => {
1236
+ const pulls = await githubJson<GitHubPullRequest[]>(
1237
+ repository,
1238
+ '/pulls?state=open&base=main&per_page=100'
1239
+ );
1240
+ const pull = selectGeneratedReleasePullRequest(pulls);
1241
+ if (!pull) {
1242
+ return undefined;
1243
+ }
1244
+ return {
1245
+ baseRefName: pull.base.ref,
1246
+ body: pull.body ?? '',
1247
+ comments: [],
1248
+ headRefName: pull.head.ref,
1249
+ ...(pull.head.sha === undefined ? {} : { headSha: pull.head.sha }),
1250
+ labels: pull.labels.map((label) => label.name),
1251
+ number: pull.number,
1252
+ title: pull.title,
1253
+ userLogin: pull.user.login,
1254
+ };
1255
+ };
1256
+
1257
+ const readPackageVersionFromRef = async (
1258
+ repository: string,
1259
+ ref: string
1260
+ ): Promise<string> => {
1261
+ const file = await githubJson<GitHubContentFile>(
1262
+ repository,
1263
+ `/contents/apps/trails/package.json?ref=${encodeURIComponent(ref)}`
1264
+ );
1265
+ const packageJson = JSON.parse(
1266
+ Buffer.from(file.content, 'base64').toString('utf8')
1267
+ ) as PackageJson;
1268
+ if (!packageJson.version) {
1269
+ throw new Error(`Missing version in apps/trails/package.json at ${ref}`);
1270
+ }
1271
+ return packageJson.version;
1272
+ };
1273
+
1274
+ export const readReleaseLabelVersions = async (
1275
+ repository: string,
1276
+ releasePullRequest: Pick<
1277
+ ReleasePolicyPullRequest,
1278
+ 'baseRefName' | 'headRefName'
1279
+ >,
1280
+ readVersion: (
1281
+ repository: string,
1282
+ ref: string
1283
+ ) => Promise<string> = readPackageVersionFromRef
1284
+ ): Promise<{
1285
+ readonly currentVersion: string;
1286
+ readonly nextVersion: string;
1287
+ }> => ({
1288
+ // The release workflow versions the checkout before labeling runs, so the
1289
+ // previous version must come from the PR base ref, never the local files.
1290
+ currentVersion: await readVersion(repository, releasePullRequest.baseRefName),
1291
+ nextVersion: await readVersion(repository, releasePullRequest.headRefName),
1292
+ });
1293
+
1294
+ const ensureGitHubLabels = async (repository: string): Promise<void> => {
1295
+ await Promise.all(
1296
+ managedGitHubLabels.map((label) =>
1297
+ runText([
1298
+ 'gh',
1299
+ 'label',
1300
+ 'create',
1301
+ label.name,
1302
+ '--repo',
1303
+ repository,
1304
+ '--color',
1305
+ label.color,
1306
+ '--description',
1307
+ label.description,
1308
+ '--force',
1309
+ ])
1310
+ )
1311
+ );
1312
+ };
1313
+
1314
+ const readCommitChangedFiles = async (
1315
+ commitSha: string
1316
+ ): Promise<ReleasePolicyChangedFile[]> => {
1317
+ const output = await runText([
1318
+ 'git',
1319
+ 'diff-tree',
1320
+ '--no-commit-id',
1321
+ '--name-status',
1322
+ '-r',
1323
+ commitSha,
1324
+ ]);
1325
+ if (!output) {
1326
+ return [];
1327
+ }
1328
+ return output.split('\n').map((line) => {
1329
+ const [status, path] = line.split(/\s+/, 2);
1330
+ if (!status || !path) {
1331
+ throw new Error(`Could not parse git diff entry: ${line}`);
1332
+ }
1333
+ return { path, status };
1334
+ });
1335
+ };
1336
+
1337
+ const readSourcePullRequests = async (
1338
+ repository: string,
1339
+ previousVersion: string,
1340
+ endRef = 'HEAD^'
1341
+ ): Promise<readonly ReleasePolicySourcePullRequest[] | undefined> => {
1342
+ const previousVersionCommit = await runText([
1343
+ 'git',
1344
+ 'log',
1345
+ '-n',
1346
+ '1',
1347
+ '--format=%H',
1348
+ '-S',
1349
+ `"version": "${previousVersion}"`,
1350
+ endRef,
1351
+ '--',
1352
+ 'apps/trails/package.json',
1353
+ ]);
1354
+ if (!previousVersionCommit) {
1355
+ return undefined;
1356
+ }
1357
+
1358
+ const output = await runText([
1359
+ 'git',
1360
+ 'rev-list',
1361
+ '--reverse',
1362
+ `${previousVersionCommit}..${endRef}`,
1363
+ ]);
1364
+ if (!output) {
1365
+ return [];
1366
+ }
1367
+
1368
+ const byNumber = new Map<number, ReleasePolicySourcePullRequest>();
1369
+ for (const commitSha of output.split('\n')) {
1370
+ const changedFiles = await readCommitChangedFiles(commitSha);
1371
+ const hasChangeset = changedFiles.some(
1372
+ (file) =>
1373
+ /^\.changeset\/[^/]+\.md$/u.test(file.path) && file.status !== 'D'
1374
+ );
1375
+ const pulls = await githubJson<GitHubPullRequest[]>(
1376
+ repository,
1377
+ `/commits/${commitSha}/pulls`
1378
+ );
1379
+ const [pull] = pulls;
1380
+
1381
+ if (!pull) {
1382
+ byNumber.set(-byNumber.size - 1, {
1383
+ commitShas: [commitSha],
1384
+ hasChangeset,
1385
+ labels: [],
1386
+ number: 0,
1387
+ title: `Unresolved source commit ${commitSha.slice(0, 7)}`,
1388
+ });
1389
+ continue;
1390
+ }
1391
+
1392
+ const existing = byNumber.get(pull.number);
1393
+ if (existing) {
1394
+ byNumber.set(pull.number, {
1395
+ ...existing,
1396
+ commitShas: [...existing.commitShas, commitSha],
1397
+ hasChangeset: existing.hasChangeset || hasChangeset,
1398
+ });
1399
+ continue;
1400
+ }
1401
+
1402
+ byNumber.set(pull.number, {
1403
+ commitShas: [commitSha],
1404
+ hasChangeset,
1405
+ labels: pull.labels.map((label) => label.name),
1406
+ ...(pull.merge_commit_sha === undefined || pull.merge_commit_sha === null
1407
+ ? {}
1408
+ : { mergeCommitSha: pull.merge_commit_sha }),
1409
+ ...(pull.merged_at === undefined ? {} : { mergedAt: pull.merged_at }),
1410
+ number: pull.number,
1411
+ title: pull.title,
1412
+ });
1413
+ }
1414
+
1415
+ return Promise.all(
1416
+ [...byNumber.values()].map((pull) =>
1417
+ enrichSourcePullRequestStackEvidence(repository, pull)
1418
+ )
1419
+ );
1420
+ };
1421
+
1422
+ const enrichSourcePullRequestStackEvidence = async (
1423
+ repository: string,
1424
+ pull: ReleasePolicySourcePullRequest
1425
+ ): Promise<ReleasePolicySourcePullRequest> => {
1426
+ if (!pull.hasChangeset || pull.number <= 0 || hasSourceStackEvidence(pull)) {
1427
+ return pull;
1428
+ }
1429
+
1430
+ if (!pull.mergedAt) {
1431
+ return pull;
1432
+ }
1433
+
1434
+ try {
1435
+ const comments = await githubJson<ReleasePolicyGitHubComment[]>(
1436
+ repository,
1437
+ `/issues/${pull.number}/comments`
1438
+ );
1439
+ if (!comments.some(isGraphiteMergeQueueComment)) {
1440
+ return pull;
1441
+ }
1442
+
1443
+ const proofSha = pull.mergeCommitSha ?? pull.commitShas.at(-1);
1444
+ if (!proofSha) {
1445
+ return pull;
1446
+ }
1447
+
1448
+ const ciState = await readCiState(repository, proofSha);
1449
+ if (ciState !== 'passed') {
1450
+ return pull;
1451
+ }
1452
+
1453
+ return {
1454
+ ...pull,
1455
+ trustedStackEvidence: `Graphite merge queue and required CI passed on ${proofSha.slice(0, 7)}`,
1456
+ };
1457
+ } catch {
1458
+ // Optional trust enrichment must fail closed into publish:manual, not abort release labeling.
1459
+ return pull;
1460
+ }
1461
+ };
1462
+
1463
+ export const isGraphiteMergeQueueComment = (
1464
+ comment: ReleasePolicyGitHubComment
1465
+ ): boolean => {
1466
+ const login = comment.user?.login ?? comment.author?.login;
1467
+ if (login !== 'graphite-app[bot]' && login !== 'graphite-app') {
1468
+ return false;
1469
+ }
1470
+ return /\bMerged by the\s+\[?Graphite merge queue\b/u.test(
1471
+ comment.body ?? ''
1472
+ );
1473
+ };
1474
+
1475
+ const readCiProof = async (
1476
+ repository: string,
1477
+ sha: string,
1478
+ releasePullRequest: ReleasePolicyPullRequest | undefined
1479
+ ): Promise<ReleasePolicyCiProof> => {
1480
+ if (process.env['TRAILS_RELEASE_POLICY_ASSUME_CI_PASSED'] === '1') {
1481
+ return {
1482
+ passed: true,
1483
+ source: 'assumed',
1484
+ summary: 'Assumed CI proof',
1485
+ };
1486
+ }
1487
+
1488
+ const [shaTreeSha, releasePullRequestHeadTreeSha] = await Promise.all([
1489
+ readCommitTreeSha(repository, sha),
1490
+ releasePullRequest?.headSha
1491
+ ? readCommitTreeSha(repository, releasePullRequest.headSha)
1492
+ : undefined,
1493
+ ]);
1494
+ const target = selectReleasePolicyCiProofTarget({
1495
+ releasePullRequest,
1496
+ releasePullRequestHeadTreeSha,
1497
+ sha,
1498
+ shaTreeSha,
1499
+ });
1500
+ const attempts = Number.parseInt(
1501
+ process.env['TRAILS_RELEASE_POLICY_CI_ATTEMPTS'] ?? '1',
1502
+ 10
1503
+ );
1504
+ const waitMs = Number.parseInt(
1505
+ process.env['TRAILS_RELEASE_POLICY_CI_WAIT_MS'] ?? '0',
1506
+ 10
1507
+ );
1508
+
1509
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
1510
+ const state = await readCiState(repository, target.sha);
1511
+ if (state === 'passed') {
1512
+ return {
1513
+ passed: true,
1514
+ source: target.source,
1515
+ summary: target.summary,
1516
+ };
1517
+ }
1518
+ if (state === 'failed') {
1519
+ return {
1520
+ passed: false,
1521
+ source: target.source,
1522
+ summary: target.summary,
1523
+ };
1524
+ }
1525
+ if (attempt < attempts && waitMs > 0) {
1526
+ console.error(
1527
+ `trails: waiting for ${target.summary.toLowerCase()} (${attempt}/${attempts})`
1528
+ );
1529
+ await Bun.sleep(waitMs);
1530
+ }
1531
+ }
1532
+
1533
+ return {
1534
+ passed: false,
1535
+ source: target.source,
1536
+ summary: target.summary,
1537
+ };
1538
+ };
1539
+
1540
+ const readCommitTreeSha = async (
1541
+ repository: string,
1542
+ sha: string
1543
+ ): Promise<string | undefined> => {
1544
+ try {
1545
+ const response = await githubJson<GitHubCommitResponse>(
1546
+ repository,
1547
+ `/commits/${sha}`
1548
+ );
1549
+ return response.commit.tree?.sha;
1550
+ } catch {
1551
+ return undefined;
1552
+ }
1553
+ };
1554
+
1555
+ const readCiState = async (
1556
+ repository: string,
1557
+ sha: string
1558
+ ): Promise<'failed' | 'passed' | 'pending'> => {
1559
+ const response = await githubJson<GitHubCheckRunsResponse>(
1560
+ repository,
1561
+ `/commits/${sha}/check-runs?per_page=100`
1562
+ );
1563
+ return ciStateFromCheckRuns(response.check_runs);
1564
+ };
1565
+
1566
+ export const ciStateFromCheckRuns = (
1567
+ runs: readonly ReleasePolicyCheckRun[]
1568
+ ): 'failed' | 'passed' | 'pending' => {
1569
+ const requiredNames = [
1570
+ 'Build',
1571
+ 'Lint & Format',
1572
+ 'Dead Code',
1573
+ 'Typecheck',
1574
+ 'Test',
1575
+ 'Governance',
1576
+ ] as const;
1577
+ const relevantRuns = runs
1578
+ .filter((run) => run.check_suite?.app?.slug === 'github-actions')
1579
+ .filter((run) =>
1580
+ requiredNames.includes(run.name as (typeof requiredNames)[number])
1581
+ );
1582
+
1583
+ for (const name of requiredNames) {
1584
+ const namedRuns = relevantRuns.filter((run) => run.name === name);
1585
+ if (namedRuns.length === 0) {
1586
+ return 'pending';
1587
+ }
1588
+ if (
1589
+ namedRuns.some(
1590
+ (run) => run.status === 'completed' && run.conclusion !== 'success'
1591
+ )
1592
+ ) {
1593
+ return 'failed';
1594
+ }
1595
+ if (
1596
+ !namedRuns.some(
1597
+ (run) => run.status === 'completed' && run.conclusion === 'success'
1598
+ )
1599
+ ) {
1600
+ return 'pending';
1601
+ }
1602
+ }
1603
+ return 'passed';
1604
+ };
1605
+
1606
+ const readPolicyInput = async (): Promise<ReleasePolicyInput> => {
1607
+ const version = await readTrailsPackageVersion();
1608
+ const distTag = await distTagForVersion(version);
1609
+ const repository =
1610
+ process.env['GITHUB_REPOSITORY'] ??
1611
+ (await runText([
1612
+ 'gh',
1613
+ 'repo',
1614
+ 'view',
1615
+ '--json',
1616
+ 'nameWithOwner',
1617
+ '--jq',
1618
+ '.nameWithOwner',
1619
+ ]));
1620
+ const ref =
1621
+ process.env['GITHUB_REF'] ??
1622
+ `refs/heads/${await runText(['git', 'branch', '--show-current'])}`;
1623
+ const sha =
1624
+ process.env['GITHUB_SHA'] ?? (await runText(['git', 'rev-parse', 'HEAD']));
1625
+ const previousVersion = await readPreviousVersion();
1626
+ const registryPackages = await readRegistryPackages(distTag);
1627
+ const [releasePullRequest, commit, changedFiles] = await Promise.all([
1628
+ readReleasePullRequest(repository, sha, { previousVersion, ref, version }),
1629
+ readCommitInfo(),
1630
+ readChangedFiles(),
1631
+ ]);
1632
+ const sourcePullRequests = previousVersion
1633
+ ? await readSourcePullRequests(repository, previousVersion)
1634
+ : undefined;
1635
+
1636
+ return {
1637
+ changedFiles,
1638
+ commit,
1639
+ distTag,
1640
+ ...(previousVersion === undefined ? {} : { previousVersion }),
1641
+ ref,
1642
+ registryPackages,
1643
+ ...(releasePullRequest === undefined ? {} : { releasePullRequest }),
1644
+ repository,
1645
+ sha,
1646
+ ...(sourcePullRequests === undefined ? {} : { sourcePullRequests }),
1647
+ version,
1648
+ };
1649
+ };
1650
+
1651
+ const commandPolicy = async (): Promise<void> => {
1652
+ let input = await readPolicyInput();
1653
+ let report = releasePolicyRequiresCiProof(input)
1654
+ ? evaluateReleasePolicy({
1655
+ ...input,
1656
+ ciProof: {
1657
+ passed: true,
1658
+ source: 'missing',
1659
+ summary: 'Deferred CI proof',
1660
+ },
1661
+ })
1662
+ : evaluateReleasePolicy(input);
1663
+
1664
+ if (report.decision === 'auto') {
1665
+ input = {
1666
+ ...input,
1667
+ ciProof: await readCiProof(
1668
+ input.repository,
1669
+ input.sha,
1670
+ input.releasePullRequest
1671
+ ),
1672
+ };
1673
+ report = evaluateReleasePolicy(input);
1674
+ }
1675
+
1676
+ printReport(report);
1677
+ await writeGitHubOutput({
1678
+ channel: report.channel ?? '',
1679
+ create_github_release: report.createGitHubRelease,
1680
+ decision: report.decision,
1681
+ publish: report.publish ?? '',
1682
+ release: report.release ?? '',
1683
+ should_publish: report.shouldPublish,
1684
+ tag: input.distTag,
1685
+ version: input.version,
1686
+ });
1687
+ if (report.decision === 'block') {
1688
+ throw new Error(
1689
+ `Release policy blocked publish: ${report.blockers.join('; ')}`
1690
+ );
1691
+ }
1692
+ };
1693
+
1694
+ const commandLabelReleasePr = async (): Promise<void> => {
1695
+ const repository =
1696
+ process.env['GITHUB_REPOSITORY'] ??
1697
+ (await runText([
1698
+ 'gh',
1699
+ 'repo',
1700
+ 'view',
1701
+ '--json',
1702
+ 'nameWithOwner',
1703
+ '--jq',
1704
+ '.nameWithOwner',
1705
+ ]));
1706
+ const releasePullRequest = await readOpenReleasePullRequest(repository);
1707
+
1708
+ if (!releasePullRequest) {
1709
+ console.error('trails: no open changeset-release/main pull request found');
1710
+ return;
1711
+ }
1712
+
1713
+ const { currentVersion, nextVersion } = await readReleaseLabelVersions(
1714
+ repository,
1715
+ releasePullRequest
1716
+ );
1717
+ const nextDistTag = await distTagForVersion(nextVersion);
1718
+ const sourcePullRequests = await readSourcePullRequests(
1719
+ repository,
1720
+ currentVersion,
1721
+ `origin/${releasePullRequest.baseRefName}`
1722
+ );
1723
+ const labelsToAdd = labelsForReleasePullRequest({
1724
+ currentVersion,
1725
+ existingLabels: releasePullRequest.labels,
1726
+ nextDistTag,
1727
+ nextVersion,
1728
+ ...(sourcePullRequests === undefined ? {} : { sourcePullRequests }),
1729
+ });
1730
+
1731
+ if (labelsToAdd.length === 0) {
1732
+ console.error(
1733
+ `trails: release PR #${releasePullRequest.number} already has release intent labels`
1734
+ );
1735
+ return;
1736
+ }
1737
+
1738
+ await ensureGitHubLabels(repository);
1739
+
1740
+ for (const label of labelsToAdd) {
1741
+ await runText([
1742
+ 'gh',
1743
+ 'issue',
1744
+ 'edit',
1745
+ String(releasePullRequest.number),
1746
+ '--repo',
1747
+ repository,
1748
+ '--add-label',
1749
+ label,
1750
+ ]);
1751
+ }
1752
+
1753
+ console.error(
1754
+ `trails: added ${labelsToAdd.join(', ')} to release PR #${releasePullRequest.number}`
1755
+ );
1756
+ };
1757
+
1758
+ export const runReleasePolicyCli = async (
1759
+ args: readonly string[] = process.argv.slice(2)
1760
+ ): Promise<number> => {
1761
+ const [command = 'policy'] = args;
1762
+ try {
1763
+ if (command === 'policy') {
1764
+ await commandPolicy();
1765
+ return 0;
1766
+ }
1767
+ if (command === 'label-release-pr') {
1768
+ await commandLabelReleasePr();
1769
+ return 0;
1770
+ }
1771
+ throw new Error(`Unknown release policy command: ${command}`);
1772
+ } catch (error) {
1773
+ console.error(error instanceof Error ? error.message : String(error));
1774
+ return 1;
1775
+ }
1776
+ };
1777
+
1778
+ if (import.meta.main) {
1779
+ process.exit(await runReleasePolicyCli(process.argv.slice(2)));
1780
+ }