@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22
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 +647 -0
- package/README.md +26 -0
- package/package.json +28 -7
- package/src/app.ts +86 -2
- package/src/clack.ts +22 -0
- package/src/cli.ts +330 -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 +153 -0
- package/src/mcp-app.ts +30 -0
- package/src/mcp-options.ts +77 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +818 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +85 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +350 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/smoke.ts +46 -0
- package/src/release/wayfinder-dogfood-smoke.ts +226 -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-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -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 +67 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +1084 -0
- package/src/trails/create-scaffold.ts +399 -104
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +185 -71
- 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 +340 -0
- package/src/trails/doctor.ts +56 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +74 -68
- package/src/trails/load-app.ts +1143 -15
- package/src/trails/project.ts +17 -3
- package/src/trails/release-check.ts +104 -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 +491 -0
- package/src/trails/run-examples.ts +145 -0
- package/src/trails/run.ts +410 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +881 -226
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +248 -0
- package/src/trails/topo-pin.ts +52 -0
- package/src/trails/topo-read-support.ts +313 -0
- package/src/trails/topo-reports.ts +807 -0
- package/src/trails/topo-store-support.ts +174 -0
- package/src/trails/topo-support.ts +220 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +106 -0
- package/src/trails/validate.ts +38 -0
- package/src/trails/version-lifecycle-support.ts +945 -0
- package/src/trails/warden-guide.ts +129 -0
- package/src/trails/warden.ts +165 -58
- 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 -62
- 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,818 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
|
|
7
|
+
import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
|
|
8
|
+
import { findPublicTrailContractChangeFacts } from './contract-facts.js';
|
|
9
|
+
import type { ContractReleaseFact } from './contract-facts.js';
|
|
10
|
+
|
|
11
|
+
interface PackageJson {
|
|
12
|
+
readonly name?: string;
|
|
13
|
+
readonly private?: boolean;
|
|
14
|
+
readonly workspaces?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WorkspaceInfo {
|
|
18
|
+
readonly isPrivate: boolean;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly relativePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ReleaseCheckInput {
|
|
24
|
+
readonly baseRef?: string;
|
|
25
|
+
readonly changedFiles: readonly string[];
|
|
26
|
+
readonly contractFacts?: readonly ContractReleaseFact[];
|
|
27
|
+
readonly noReleaseOverride?: boolean;
|
|
28
|
+
readonly releaseConfig?: ReleaseConfigInput;
|
|
29
|
+
/** Compatibility alias for the existing GitHub label and package script. */
|
|
30
|
+
readonly releaseNone?: boolean;
|
|
31
|
+
readonly repoRoot: string;
|
|
32
|
+
readonly workspaces: readonly WorkspaceInfo[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReleaseCheckResult {
|
|
36
|
+
readonly affectedPackages: readonly string[];
|
|
37
|
+
readonly changedChangesets: readonly string[];
|
|
38
|
+
readonly contractFacts: readonly ContractReleaseFact[];
|
|
39
|
+
readonly coveredPackages: readonly string[];
|
|
40
|
+
readonly errors: readonly string[];
|
|
41
|
+
readonly matchedRuleIds: readonly string[];
|
|
42
|
+
readonly noReleaseOverride: boolean;
|
|
43
|
+
readonly passed: boolean;
|
|
44
|
+
/** Compatibility alias for the existing GitHub label and package script. */
|
|
45
|
+
readonly releaseNone: boolean;
|
|
46
|
+
readonly uncoveredContractFacts: readonly ContractReleaseFact[];
|
|
47
|
+
readonly versionRelease: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CliOptions {
|
|
51
|
+
readonly baseRef?: string;
|
|
52
|
+
readonly changedFilesPath?: string;
|
|
53
|
+
readonly configPath?: string;
|
|
54
|
+
readonly releaseNone: boolean;
|
|
55
|
+
readonly repoRoot: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ReleaseConfigLoadResult {
|
|
59
|
+
readonly config?: ReleaseConfigInput | undefined;
|
|
60
|
+
readonly configPath?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunReleaseCheckOptions {
|
|
64
|
+
readonly baseRef?: string;
|
|
65
|
+
readonly changedFilesPath?: string;
|
|
66
|
+
readonly configPath?: string;
|
|
67
|
+
readonly env?: Record<string, string | undefined>;
|
|
68
|
+
readonly releaseNone?: boolean;
|
|
69
|
+
readonly repoRoot: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ReleaseCheckReport extends ReleaseCheckResult {
|
|
73
|
+
readonly configPath?: string;
|
|
74
|
+
readonly formatted: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NON_SHIPPING_PACKAGE_PATTERNS = [
|
|
78
|
+
/(?:^|\/)__tests__(?:\/|$)/u,
|
|
79
|
+
/(?:^|\/)__snapshots__(?:\/|$)/u,
|
|
80
|
+
/(?:^|\/)dist(?:\/|$)/u,
|
|
81
|
+
/(?:^|\/)\.turbo(?:\/|$)/u,
|
|
82
|
+
/(?:^|\/)node_modules(?:\/|$)/u,
|
|
83
|
+
/\.(?:test|spec|snap)\.[cm]?[jt]sx?$/u,
|
|
84
|
+
/\.test-d\.ts$/u,
|
|
85
|
+
/\.tsbuildinfo$/u,
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
const CHANGESET_PATH_PATTERN = /^\.changeset\/[^/]+\.md$/u;
|
|
89
|
+
const CHANGESET_PACKAGE_PATTERN =
|
|
90
|
+
/^['"]?(@ontrails\/[^'":]+)['"]?\s*:\s*(?:major|minor|patch)$/u;
|
|
91
|
+
const CHANGESET_PRERELEASE_STATE_PATH = '.changeset/pre.json';
|
|
92
|
+
const WORKSPACE_GLOB_SYNTAX_PATTERN = /[*?[\]{}]/u;
|
|
93
|
+
const VERSION_RELEASE_WORKSPACE_FILES = new Set([
|
|
94
|
+
'CHANGELOG.md',
|
|
95
|
+
'package.json',
|
|
96
|
+
]);
|
|
97
|
+
const CONFIG_CANDIDATES = [
|
|
98
|
+
'trails.config.ts',
|
|
99
|
+
'trails.config.mts',
|
|
100
|
+
'trails.config.js',
|
|
101
|
+
'trails.config.mjs',
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
const normalizePath = (path: string): string =>
|
|
105
|
+
path.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
106
|
+
|
|
107
|
+
const hasWorkspaceGlobSyntax = (pattern: string): boolean =>
|
|
108
|
+
WORKSPACE_GLOB_SYNTAX_PATTERN.test(pattern);
|
|
109
|
+
|
|
110
|
+
const readJson = async <T>(path: string): Promise<T> =>
|
|
111
|
+
(await Bun.file(path).json()) as T;
|
|
112
|
+
|
|
113
|
+
const discoverWorkspaceDirs = async (
|
|
114
|
+
repoRoot: string,
|
|
115
|
+
patterns: readonly string[]
|
|
116
|
+
): Promise<string[]> => {
|
|
117
|
+
const dirs: string[] = [];
|
|
118
|
+
|
|
119
|
+
for (const pattern of patterns) {
|
|
120
|
+
if (pattern.endsWith('/*')) {
|
|
121
|
+
const parent = join(repoRoot, pattern.slice(0, -2));
|
|
122
|
+
let names: string[] = [];
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
126
|
+
names = entries
|
|
127
|
+
.filter((entry) => entry.isDirectory())
|
|
128
|
+
.map((entry) =>
|
|
129
|
+
typeof entry.name === 'string' ? entry.name : String(entry.name)
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const name of names) {
|
|
136
|
+
const dir = join(parent, name);
|
|
137
|
+
|
|
138
|
+
if (await Bun.file(join(dir, 'package.json')).exists()) {
|
|
139
|
+
dirs.push(dir);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const dir = join(repoRoot, pattern);
|
|
147
|
+
|
|
148
|
+
if (await Bun.file(join(dir, 'package.json')).exists()) {
|
|
149
|
+
dirs.push(dir);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (hasWorkspaceGlobSyntax(pattern)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Unsupported workspace pattern '${pattern}'. The release check supports exact workspace paths and one-level '/*' globs.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Workspace pattern '${pattern}' did not resolve to a package.json`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return dirs;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const discoverWorkspaces = async (
|
|
168
|
+
repoRoot: string
|
|
169
|
+
): Promise<readonly WorkspaceInfo[]> => {
|
|
170
|
+
const root = await readJson<PackageJson>(join(repoRoot, 'package.json'));
|
|
171
|
+
|
|
172
|
+
if (!root.workspaces || root.workspaces.length === 0) {
|
|
173
|
+
throw new Error('Root package.json has no workspaces field');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces);
|
|
177
|
+
const workspaces: WorkspaceInfo[] = [];
|
|
178
|
+
|
|
179
|
+
for (const dir of dirs) {
|
|
180
|
+
const pkg = await readJson<PackageJson>(join(dir, 'package.json'));
|
|
181
|
+
|
|
182
|
+
if (!pkg.name) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
workspaces.push({
|
|
187
|
+
isPrivate: pkg.private === true,
|
|
188
|
+
name: pkg.name,
|
|
189
|
+
relativePath: normalizePath(relative(repoRoot, dir)),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return workspaces.toSorted((left, right) =>
|
|
194
|
+
left.relativePath.localeCompare(right.relativePath)
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const isPublishableOnTrailsWorkspace = (workspace: WorkspaceInfo): boolean =>
|
|
199
|
+
!workspace.isPrivate && workspace.name.startsWith('@ontrails/');
|
|
200
|
+
|
|
201
|
+
const isUnderWorkspace = (filePath: string, workspacePath: string): boolean =>
|
|
202
|
+
filePath === workspacePath || filePath.startsWith(`${workspacePath}/`);
|
|
203
|
+
|
|
204
|
+
const getWorkspaceRelativePath = (
|
|
205
|
+
filePath: string,
|
|
206
|
+
workspacePath: string
|
|
207
|
+
): string => filePath.slice(workspacePath.length + 1);
|
|
208
|
+
|
|
209
|
+
const isNonShippingPackagePath = (workspaceRelativePath: string): boolean =>
|
|
210
|
+
NON_SHIPPING_PACKAGE_PATTERNS.some((pattern) =>
|
|
211
|
+
pattern.test(workspaceRelativePath)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const findAffectedPackages = (
|
|
215
|
+
changedFiles: readonly string[],
|
|
216
|
+
workspaces: readonly WorkspaceInfo[]
|
|
217
|
+
): readonly string[] => {
|
|
218
|
+
const affected = new Set<string>();
|
|
219
|
+
const publishableWorkspaces = workspaces.filter(
|
|
220
|
+
isPublishableOnTrailsWorkspace
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
for (const file of changedFiles.map(normalizePath)) {
|
|
224
|
+
for (const workspace of publishableWorkspaces) {
|
|
225
|
+
if (!isUnderWorkspace(file, workspace.relativePath)) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const workspaceRelativePath = getWorkspaceRelativePath(
|
|
230
|
+
file,
|
|
231
|
+
workspace.relativePath
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (isNonShippingPackagePath(workspaceRelativePath)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
affected.add(workspace.name);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return [...affected].toSorted();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const isVersionReleaseChangeSet = (
|
|
246
|
+
changedFiles: readonly string[],
|
|
247
|
+
workspaces: readonly WorkspaceInfo[],
|
|
248
|
+
coveredPackages: readonly string[]
|
|
249
|
+
): boolean => {
|
|
250
|
+
const normalizedFiles = changedFiles.map(normalizePath);
|
|
251
|
+
const coveredPackageSet = new Set(coveredPackages);
|
|
252
|
+
|
|
253
|
+
if (!normalizedFiles.includes(CHANGESET_PRERELEASE_STATE_PATH)) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const publishableWorkspaces = workspaces.filter(
|
|
258
|
+
isPublishableOnTrailsWorkspace
|
|
259
|
+
);
|
|
260
|
+
let hasWorkspaceVersionFile = false;
|
|
261
|
+
|
|
262
|
+
for (const file of normalizedFiles) {
|
|
263
|
+
for (const workspace of publishableWorkspaces) {
|
|
264
|
+
if (!isUnderWorkspace(file, workspace.relativePath)) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const workspaceRelativePath = getWorkspaceRelativePath(
|
|
269
|
+
file,
|
|
270
|
+
workspace.relativePath
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (isNonShippingPackagePath(workspaceRelativePath)) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!VERSION_RELEASE_WORKSPACE_FILES.has(workspaceRelativePath)) {
|
|
278
|
+
if (!coveredPackageSet.has(workspace.name)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
hasWorkspaceVersionFile = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return hasWorkspaceVersionFile;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const parseChangesetPackages = (content: string): readonly string[] => {
|
|
293
|
+
const lines = content.split(/\r?\n/u);
|
|
294
|
+
|
|
295
|
+
if (lines[0] !== '---') {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const closingIndex = lines.slice(1).indexOf('---');
|
|
300
|
+
|
|
301
|
+
if (closingIndex === -1) {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return lines.slice(1, closingIndex + 1).flatMap((line): string[] => {
|
|
306
|
+
const match = line.match(CHANGESET_PACKAGE_PATTERN);
|
|
307
|
+
return match?.[1] ? [match[1]] : [];
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const findChangedChangesetPaths = (
|
|
312
|
+
changedFiles: readonly string[]
|
|
313
|
+
): readonly string[] =>
|
|
314
|
+
changedFiles
|
|
315
|
+
.map(normalizePath)
|
|
316
|
+
.filter((path) => CHANGESET_PATH_PATTERN.test(path));
|
|
317
|
+
|
|
318
|
+
const findChangedChangesets = (
|
|
319
|
+
changedChangesetPaths: readonly string[],
|
|
320
|
+
repoRoot: string
|
|
321
|
+
): readonly {
|
|
322
|
+
readonly packages: readonly string[];
|
|
323
|
+
readonly path: string;
|
|
324
|
+
}[] =>
|
|
325
|
+
changedChangesetPaths.flatMap((path) => {
|
|
326
|
+
const absolutePath = join(repoRoot, path);
|
|
327
|
+
|
|
328
|
+
if (!existsSync(absolutePath)) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const packages = parseChangesetPackages(readFileSync(absolutePath, 'utf8'));
|
|
333
|
+
|
|
334
|
+
return packages.length === 0 ? [] : [{ packages, path }];
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const findGateContractFacts = (
|
|
338
|
+
input: ReleaseCheckInput
|
|
339
|
+
): readonly ContractReleaseFact[] =>
|
|
340
|
+
input.contractFacts ??
|
|
341
|
+
findPublicTrailContractChangeFacts({
|
|
342
|
+
...(input.baseRef === undefined ? {} : { baseRef: input.baseRef }),
|
|
343
|
+
changedFiles: input.changedFiles,
|
|
344
|
+
repoRoot: input.repoRoot,
|
|
345
|
+
workspaces: input.workspaces,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const isContractFactCovered = (
|
|
349
|
+
fact: ContractReleaseFact,
|
|
350
|
+
coveredPackages: readonly string[]
|
|
351
|
+
): boolean =>
|
|
352
|
+
fact.packageName !== undefined && coveredPackages.includes(fact.packageName);
|
|
353
|
+
|
|
354
|
+
const formatContractFact = (fact: ContractReleaseFact): string =>
|
|
355
|
+
`${fact.trailId} ${fact.aspect} (${fact.packageName ?? fact.path})`;
|
|
356
|
+
|
|
357
|
+
const ruleMatchesFactType = (
|
|
358
|
+
input: ReleaseCheckInput,
|
|
359
|
+
factType: ReleaseFactType
|
|
360
|
+
): boolean => {
|
|
361
|
+
const releaseConfig = input.releaseConfig
|
|
362
|
+
? releaseConfigSchema.parse(input.releaseConfig)
|
|
363
|
+
: defaultReleaseConfig;
|
|
364
|
+
return releaseConfig.rules.some(
|
|
365
|
+
(rule) =>
|
|
366
|
+
rule.enabled && rule.severity === 'error' && rule.facts.includes(factType)
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const findMatchedRuleIds = (input: ReleaseCheckInput): readonly string[] => {
|
|
371
|
+
const releaseConfig = input.releaseConfig
|
|
372
|
+
? releaseConfigSchema.parse(input.releaseConfig)
|
|
373
|
+
: defaultReleaseConfig;
|
|
374
|
+
return releaseConfig.rules
|
|
375
|
+
.filter((rule) => rule.enabled && rule.severity === 'error')
|
|
376
|
+
.map((rule) => rule.id)
|
|
377
|
+
.toSorted();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export const checkReleaseRules = (
|
|
381
|
+
input: ReleaseCheckInput
|
|
382
|
+
): ReleaseCheckResult => {
|
|
383
|
+
const noReleaseOverride =
|
|
384
|
+
input.noReleaseOverride === true || input.releaseNone === true;
|
|
385
|
+
const affectedPackages = findAffectedPackages(
|
|
386
|
+
input.changedFiles,
|
|
387
|
+
input.workspaces
|
|
388
|
+
);
|
|
389
|
+
const changedChangesets = findChangedChangesetPaths(input.changedFiles);
|
|
390
|
+
const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
|
|
391
|
+
const coveredPackages = [
|
|
392
|
+
...new Set(changesets.flatMap((changeset) => changeset.packages)),
|
|
393
|
+
].toSorted();
|
|
394
|
+
const contractFacts = findGateContractFacts(input);
|
|
395
|
+
const versionRelease = isVersionReleaseChangeSet(
|
|
396
|
+
input.changedFiles,
|
|
397
|
+
input.workspaces,
|
|
398
|
+
coveredPackages
|
|
399
|
+
);
|
|
400
|
+
const uncoveredPackages = affectedPackages.filter(
|
|
401
|
+
(packageName) => !coveredPackages.includes(packageName)
|
|
402
|
+
);
|
|
403
|
+
const uncoveredContractFacts = contractFacts.filter(
|
|
404
|
+
(fact) => !isContractFactCovered(fact, coveredPackages)
|
|
405
|
+
);
|
|
406
|
+
const matchedRuleIds = findMatchedRuleIds(input);
|
|
407
|
+
const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
|
|
408
|
+
const requiresPublicContractIntent = ruleMatchesFactType(
|
|
409
|
+
input,
|
|
410
|
+
'public-trail-contract'
|
|
411
|
+
);
|
|
412
|
+
const errors: string[] = [];
|
|
413
|
+
|
|
414
|
+
if (noReleaseOverride && changedChangesets.length > 0) {
|
|
415
|
+
errors.push(
|
|
416
|
+
'`release:none` conflicts with changed changeset files. Remove the label or the changeset.'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (
|
|
421
|
+
!noReleaseOverride &&
|
|
422
|
+
!versionRelease &&
|
|
423
|
+
requiresPackageIntent &&
|
|
424
|
+
uncoveredPackages.length > 0
|
|
425
|
+
) {
|
|
426
|
+
errors.push(
|
|
427
|
+
`Release rules require intent for package content changes: ${uncoveredPackages.join(', ')}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (
|
|
432
|
+
!noReleaseOverride &&
|
|
433
|
+
!versionRelease &&
|
|
434
|
+
requiresPublicContractIntent &&
|
|
435
|
+
uncoveredContractFacts.length > 0
|
|
436
|
+
) {
|
|
437
|
+
errors.push(
|
|
438
|
+
`Release rules require intent for public trail contract changes: ${uncoveredContractFacts
|
|
439
|
+
.map(formatContractFact)
|
|
440
|
+
.join(', ')}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
affectedPackages,
|
|
446
|
+
changedChangesets,
|
|
447
|
+
contractFacts,
|
|
448
|
+
coveredPackages,
|
|
449
|
+
errors,
|
|
450
|
+
matchedRuleIds,
|
|
451
|
+
noReleaseOverride,
|
|
452
|
+
passed: errors.length === 0,
|
|
453
|
+
releaseNone: noReleaseOverride,
|
|
454
|
+
uncoveredContractFacts,
|
|
455
|
+
versionRelease,
|
|
456
|
+
};
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const readChangedFiles = (path: string): readonly string[] =>
|
|
460
|
+
readFileSync(path, 'utf8')
|
|
461
|
+
.split(/\r?\n/u)
|
|
462
|
+
.map((line) => line.trim())
|
|
463
|
+
.filter((line) => line.length > 0);
|
|
464
|
+
|
|
465
|
+
const parseChangedFilesOutput = (output: string): readonly string[] =>
|
|
466
|
+
output
|
|
467
|
+
.split(/\r?\n/u)
|
|
468
|
+
.map((line) => line.trim())
|
|
469
|
+
.filter((line) => line.length > 0);
|
|
470
|
+
|
|
471
|
+
const readLocalChangedFiles = (
|
|
472
|
+
repoRoot: string,
|
|
473
|
+
baseRef: string
|
|
474
|
+
): readonly string[] => {
|
|
475
|
+
const result = Bun.spawnSync({
|
|
476
|
+
cmd: [
|
|
477
|
+
'git',
|
|
478
|
+
'diff',
|
|
479
|
+
'--name-only',
|
|
480
|
+
'--diff-filter=ACMRTUXB',
|
|
481
|
+
`${baseRef}...HEAD`,
|
|
482
|
+
'--',
|
|
483
|
+
'.',
|
|
484
|
+
],
|
|
485
|
+
cwd: repoRoot,
|
|
486
|
+
stderr: 'pipe',
|
|
487
|
+
stdout: 'pipe',
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (result.exitCode !== 0) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Failed to derive local changed files: ${result.stderr.toString()}`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return parseChangedFilesOutput(result.stdout.toString());
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
500
|
+
typeof value === 'object' && value !== null;
|
|
501
|
+
|
|
502
|
+
interface ResultLike {
|
|
503
|
+
readonly error?: unknown;
|
|
504
|
+
readonly value?: unknown;
|
|
505
|
+
isErr(): boolean;
|
|
506
|
+
isOk(): boolean;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const isResultLike = (value: unknown): value is ResultLike =>
|
|
510
|
+
isRecord(value) &&
|
|
511
|
+
typeof value['isOk'] === 'function' &&
|
|
512
|
+
typeof value['isErr'] === 'function';
|
|
513
|
+
|
|
514
|
+
interface ResolvableConfig {
|
|
515
|
+
resolve(options: {
|
|
516
|
+
readonly cwd: string;
|
|
517
|
+
readonly env: Record<string, string | undefined>;
|
|
518
|
+
}): Promise<unknown>;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
|
|
522
|
+
isRecord(value) && typeof value['resolve'] === 'function';
|
|
523
|
+
|
|
524
|
+
const errorMessage = (error: unknown): string =>
|
|
525
|
+
error instanceof Error ? error.message : String(error);
|
|
526
|
+
|
|
527
|
+
const findConfigPath = (
|
|
528
|
+
repoRoot: string,
|
|
529
|
+
configPath: string | undefined
|
|
530
|
+
): string | undefined => {
|
|
531
|
+
if (configPath !== undefined) {
|
|
532
|
+
const resolvedPath = resolve(repoRoot, configPath);
|
|
533
|
+
if (!existsSync(resolvedPath)) {
|
|
534
|
+
throw new Error(`Release config file not found: ${resolvedPath}`);
|
|
535
|
+
}
|
|
536
|
+
return resolvedPath;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return CONFIG_CANDIDATES.map((entry) => resolve(repoRoot, entry)).find(
|
|
540
|
+
(entry) => existsSync(entry)
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
|
|
545
|
+
isRecord(value) && 'release' in value
|
|
546
|
+
? (value['release'] as ReleaseConfigInput)
|
|
547
|
+
: undefined;
|
|
548
|
+
|
|
549
|
+
const importConfigModule = async (
|
|
550
|
+
configPath: string
|
|
551
|
+
): Promise<Record<string, unknown>> => {
|
|
552
|
+
const url = pathToFileURL(configPath);
|
|
553
|
+
url.searchParams.set('t', Date.now().toString());
|
|
554
|
+
return (await import(url.href)) as Record<string, unknown>;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
export const loadReleaseConfig = async ({
|
|
558
|
+
configPath,
|
|
559
|
+
env = {},
|
|
560
|
+
repoRoot,
|
|
561
|
+
}: {
|
|
562
|
+
readonly configPath?: string | undefined;
|
|
563
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
564
|
+
readonly repoRoot: string;
|
|
565
|
+
}): Promise<ReleaseConfigLoadResult> => {
|
|
566
|
+
const locatedConfigPath = findConfigPath(repoRoot, configPath);
|
|
567
|
+
if (locatedConfigPath === undefined) {
|
|
568
|
+
return {};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const mod = await importConfigModule(locatedConfigPath);
|
|
573
|
+
const exported = mod['default'] ?? mod;
|
|
574
|
+
|
|
575
|
+
if (isResolvableConfig(exported)) {
|
|
576
|
+
const resolved = await exported.resolve({ cwd: repoRoot, env });
|
|
577
|
+
if (isResultLike(resolved)) {
|
|
578
|
+
if (resolved.isOk()) {
|
|
579
|
+
return {
|
|
580
|
+
config: extractReleaseConfig(resolved.value),
|
|
581
|
+
configPath: locatedConfigPath,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
throw new Error(
|
|
585
|
+
`Failed to resolve release config: ${errorMessage(resolved.error)}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
config: extractReleaseConfig(resolved),
|
|
591
|
+
configPath: locatedConfigPath,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
config: extractReleaseConfig(exported),
|
|
597
|
+
configPath: locatedConfigPath,
|
|
598
|
+
};
|
|
599
|
+
} catch (error) {
|
|
600
|
+
throw new Error(`Failed to load release config: ${errorMessage(error)}`, {
|
|
601
|
+
cause: error,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const parseArgs = (args: readonly string[]): CliOptions => {
|
|
607
|
+
let baseRef: string | undefined;
|
|
608
|
+
let changedFilesPath: string | undefined;
|
|
609
|
+
let configPath: string | undefined;
|
|
610
|
+
let releaseNone = false;
|
|
611
|
+
let repoRoot = process.cwd();
|
|
612
|
+
|
|
613
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
614
|
+
const arg = args[index];
|
|
615
|
+
|
|
616
|
+
if (arg === '--release-none') {
|
|
617
|
+
releaseNone = true;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (arg === '--base-ref') {
|
|
622
|
+
const value = args[index + 1];
|
|
623
|
+
|
|
624
|
+
if (!value) {
|
|
625
|
+
throw new Error('--base-ref requires a git ref or commit');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
baseRef = value;
|
|
629
|
+
index += 1;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (arg === '--changed-files') {
|
|
634
|
+
const value = args[index + 1];
|
|
635
|
+
|
|
636
|
+
if (!value) {
|
|
637
|
+
throw new Error('--changed-files requires a file path');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
changedFilesPath = value;
|
|
641
|
+
index += 1;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (arg === '--config-path') {
|
|
646
|
+
const value = args[index + 1];
|
|
647
|
+
|
|
648
|
+
if (!value) {
|
|
649
|
+
throw new Error('--config-path requires a config file path');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
configPath = value;
|
|
653
|
+
index += 1;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (arg === '--repo-root') {
|
|
658
|
+
const value = args[index + 1];
|
|
659
|
+
|
|
660
|
+
if (!value) {
|
|
661
|
+
throw new Error('--repo-root requires a directory path');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
repoRoot = resolve(value);
|
|
665
|
+
index += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
throw new Error(`Unknown argument: ${arg ?? ''}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
...(baseRef === undefined ? {} : { baseRef }),
|
|
674
|
+
...(changedFilesPath === undefined ? {} : { changedFilesPath }),
|
|
675
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
676
|
+
releaseNone,
|
|
677
|
+
repoRoot,
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
export const formatReleaseCheckReport = (
|
|
682
|
+
result: ReleaseCheckResult
|
|
683
|
+
): string => {
|
|
684
|
+
const lines: string[] = [];
|
|
685
|
+
|
|
686
|
+
if (result.passed) {
|
|
687
|
+
if (result.affectedPackages.length === 0) {
|
|
688
|
+
lines.push(
|
|
689
|
+
'Release check passed: no publishable package content files changed.'
|
|
690
|
+
);
|
|
691
|
+
return lines.join('\n');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (result.noReleaseOverride) {
|
|
695
|
+
lines.push(
|
|
696
|
+
`Release check passed via release:none override for: ${result.affectedPackages.join(', ')}`
|
|
697
|
+
);
|
|
698
|
+
return lines.join('\n');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (result.versionRelease) {
|
|
702
|
+
lines.push(
|
|
703
|
+
`Release check passed for generated version release: ${result.affectedPackages.join(', ')}`
|
|
704
|
+
);
|
|
705
|
+
return lines.join('\n');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
lines.push(
|
|
709
|
+
`Release check passed for: ${result.affectedPackages.join(', ')}`
|
|
710
|
+
);
|
|
711
|
+
lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
|
|
712
|
+
return lines.join('\n');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
for (const error of result.errors) {
|
|
716
|
+
lines.push(error);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (result.affectedPackages.length > 0) {
|
|
720
|
+
lines.push(`Affected packages: ${result.affectedPackages.join(', ')}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (result.contractFacts.length > 0) {
|
|
724
|
+
lines.push(
|
|
725
|
+
`Public trail contract facts: ${result.contractFacts
|
|
726
|
+
.map(formatContractFact)
|
|
727
|
+
.join(', ')}`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (result.changedChangesets.length > 0) {
|
|
732
|
+
lines.push(`Changed changesets: ${result.changedChangesets.join(', ')}`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return lines.join('\n');
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const renderResult = (result: ReleaseCheckResult): void => {
|
|
739
|
+
const formatted = formatReleaseCheckReport(result);
|
|
740
|
+
if (formatted.length === 0) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (result.passed) {
|
|
745
|
+
console.log(formatted);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
console.error(formatted);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
export const runReleaseCheck = async (
|
|
753
|
+
options: RunReleaseCheckOptions
|
|
754
|
+
): Promise<ReleaseCheckReport> => {
|
|
755
|
+
const workspaces = await discoverWorkspaces(options.repoRoot);
|
|
756
|
+
const baseRef =
|
|
757
|
+
options.baseRef ??
|
|
758
|
+
(options.changedFilesPath === undefined ? 'origin/main' : undefined);
|
|
759
|
+
const changedFiles = options.changedFilesPath
|
|
760
|
+
? readChangedFiles(options.changedFilesPath)
|
|
761
|
+
: readLocalChangedFiles(options.repoRoot, baseRef ?? 'origin/main');
|
|
762
|
+
const loadedConfig = await loadReleaseConfig({
|
|
763
|
+
...(options.configPath === undefined
|
|
764
|
+
? {}
|
|
765
|
+
: { configPath: options.configPath }),
|
|
766
|
+
env: options.env,
|
|
767
|
+
repoRoot: options.repoRoot,
|
|
768
|
+
});
|
|
769
|
+
const result = checkReleaseRules({
|
|
770
|
+
...(baseRef === undefined ? {} : { baseRef }),
|
|
771
|
+
changedFiles,
|
|
772
|
+
...(loadedConfig.config === undefined
|
|
773
|
+
? {}
|
|
774
|
+
: { releaseConfig: loadedConfig.config }),
|
|
775
|
+
releaseNone: options.releaseNone === true,
|
|
776
|
+
repoRoot: options.repoRoot,
|
|
777
|
+
workspaces,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
...result,
|
|
782
|
+
...(loadedConfig.configPath === undefined
|
|
783
|
+
? {}
|
|
784
|
+
: { configPath: loadedConfig.configPath }),
|
|
785
|
+
formatted: formatReleaseCheckReport(result),
|
|
786
|
+
};
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
export const runReleaseCheckCli = async (
|
|
790
|
+
args: readonly string[]
|
|
791
|
+
): Promise<number> => {
|
|
792
|
+
const options = parseArgs(args);
|
|
793
|
+
const result = await runReleaseCheck({
|
|
794
|
+
...(options.baseRef === undefined ? {} : { baseRef: options.baseRef }),
|
|
795
|
+
...(options.changedFilesPath === undefined
|
|
796
|
+
? {}
|
|
797
|
+
: { changedFilesPath: options.changedFilesPath }),
|
|
798
|
+
...(options.configPath === undefined
|
|
799
|
+
? {}
|
|
800
|
+
: { configPath: options.configPath }),
|
|
801
|
+
env: process.env as Record<string, string | undefined>,
|
|
802
|
+
releaseNone: options.releaseNone,
|
|
803
|
+
repoRoot: options.repoRoot,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
renderResult(result);
|
|
807
|
+
|
|
808
|
+
return result.passed ? 0 : 1;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
if (import.meta.main) {
|
|
812
|
+
try {
|
|
813
|
+
process.exit(await runReleaseCheckCli(process.argv.slice(2)));
|
|
814
|
+
} catch (error) {
|
|
815
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
816
|
+
process.exit(1);
|
|
817
|
+
}
|
|
818
|
+
}
|