@ontrails/trails 1.0.0-beta.3 → 1.0.0-beta.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +975 -0
- package/README.md +27 -0
- package/package.json +30 -7
- package/src/app.ts +125 -2
- package/src/cli.ts +345 -11
- package/src/completions.ts +240 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +173 -0
- package/src/mcp-app.ts +40 -0
- package/src/mcp-options.ts +77 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/config.ts +152 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +844 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +125 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +754 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1684 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +46 -0
- package/src/release/wayfinder-dogfood-smoke.ts +760 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +41 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/run-wayfind-outline.ts +166 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +94 -40
- package/src/trails/add-trail.ts +79 -41
- package/src/trails/add-verify.ts +95 -25
- package/src/trails/compile.ts +59 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +1024 -0
- package/src/trails/create-scaffold.ts +399 -104
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +186 -72
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +82 -0
- package/src/trails/dev-reset.ts +50 -0
- package/src/trails/dev-stats.ts +72 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +71 -69
- package/src/trails/load-app.ts +1142 -16
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project.ts +17 -3
- package/src/trails/regrade.ts +292 -0
- package/src/trails/release-check.ts +105 -0
- package/src/trails/release-smoke.ts +48 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +475 -0
- package/src/trails/run-examples.ts +129 -0
- package/src/trails/run.ts +434 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +896 -227
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +363 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +307 -0
- package/src/trails/topo-support.ts +219 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +27 -0
- package/src/trails/version-lifecycle-support.ts +935 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +198 -58
- package/src/trails/wayfind.ts +1001 -0
- package/src/versions.ts +31 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -6
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -11
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -13
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -88
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -11
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -85
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -67
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -288
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -22
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -121
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -11
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -80
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -4
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -24
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -43
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -33
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -225
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -19
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -88
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/create.test.ts +0 -349
- package/src/__tests__/guide.test.ts +0 -91
- package/src/__tests__/load-app.test.ts +0 -15
- package/src/__tests__/survey.test.ts +0 -161
- package/src/__tests__/warden.test.ts +0 -74
- 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
|
+
}
|