@ontrails/trails 1.0.0-beta.3 → 1.0.0-beta.30

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