@ontrails/trails 1.0.0-beta.42 → 1.0.0-beta.43
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 +55 -0
- package/README.md +1 -1
- package/package.json +16 -17
- package/src/mcp-options.ts +1 -0
- package/src/regrade/audit.ts +482 -0
- package/src/regrade/history.ts +250 -42
- package/src/regrade/live-api-preserve.ts +117 -2
- package/src/regrade/plan-artifact.ts +192 -13
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/source-transaction.ts +75 -0
- package/src/release/check.ts +312 -10
- package/src/release/config.ts +10 -0
- package/src/release/index.ts +5 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/run-completions-install.ts +4 -4
- package/src/run-trace.ts +4 -4
- package/src/trails/dev-support.ts +1 -1
- package/src/trails/regrade.ts +802 -125
- package/src/trails/release-check.ts +8 -0
- package/src/trails/run-example.ts +2 -2
- package/src/trails/survey.ts +6 -6
- package/src/trails/topo-output-schemas.ts +8 -8
- package/src/trails/topo-reports.ts +44 -44
- package/src/trails/topo-store-support.ts +2 -4
- package/src/trails/warden-guide.ts +1 -1
package/src/release/check.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
|
|
|
8
8
|
import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
|
|
9
9
|
import { findPublicTrailContractChangeFacts } from './contract-facts.js';
|
|
10
10
|
import type { ContractReleaseFact } from './contract-facts.js';
|
|
11
|
+
import { findPackageRouteReleaseFacts } from './package-route-facts.js';
|
|
12
|
+
import type { PackageRouteReleaseFact } from './package-route-facts.js';
|
|
11
13
|
|
|
12
14
|
interface PackageJson {
|
|
13
15
|
readonly name?: string;
|
|
@@ -23,6 +25,8 @@ export interface WorkspaceInfo {
|
|
|
23
25
|
|
|
24
26
|
export interface ReleaseCheckInput {
|
|
25
27
|
readonly baseRef?: string;
|
|
28
|
+
readonly baseWorkspaceError?: string;
|
|
29
|
+
readonly baseWorkspaces?: readonly WorkspaceInfo[];
|
|
26
30
|
readonly changedFiles: readonly string[];
|
|
27
31
|
readonly contractFacts?: readonly ContractReleaseFact[];
|
|
28
32
|
readonly noReleaseOverride?: boolean;
|
|
@@ -42,6 +46,7 @@ export interface ReleaseCheckResult {
|
|
|
42
46
|
readonly errors: readonly string[];
|
|
43
47
|
readonly matchedRuleIds: readonly string[];
|
|
44
48
|
readonly noReleaseOverride: boolean;
|
|
49
|
+
readonly packageRouteFacts: readonly PackageRouteReleaseFact[];
|
|
45
50
|
readonly passed: boolean;
|
|
46
51
|
/** Compatibility alias for the existing GitHub label and package script. */
|
|
47
52
|
readonly releaseNone: boolean;
|
|
@@ -97,11 +102,21 @@ const VERSION_RELEASE_WORKSPACE_FILES = new Set([
|
|
|
97
102
|
'package.json',
|
|
98
103
|
]);
|
|
99
104
|
const normalizePath = (path: string): string =>
|
|
100
|
-
path.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
105
|
+
path.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, '');
|
|
106
|
+
|
|
107
|
+
const normalizeWorkspacePattern = (pattern: string): string =>
|
|
108
|
+
normalizePath(pattern) || '.';
|
|
101
109
|
|
|
102
110
|
const hasWorkspaceGlobSyntax = (pattern: string): boolean =>
|
|
103
111
|
WORKSPACE_GLOB_SYNTAX_PATTERN.test(pattern);
|
|
104
112
|
|
|
113
|
+
const isSupportedWorkspacePattern = (pattern: string): boolean =>
|
|
114
|
+
!hasWorkspaceGlobSyntax(pattern) ||
|
|
115
|
+
(pattern.endsWith('/*') && !hasWorkspaceGlobSyntax(pattern.slice(0, -2)));
|
|
116
|
+
|
|
117
|
+
const unsupportedWorkspacePatternError = (pattern: string): string =>
|
|
118
|
+
`Unsupported workspace pattern '${pattern}'. The release check supports exact workspace paths and one-level '/*' globs.`;
|
|
119
|
+
|
|
105
120
|
const readJson = async <T>(path: string): Promise<T> =>
|
|
106
121
|
(await Bun.file(path).json()) as T;
|
|
107
122
|
|
|
@@ -112,6 +127,10 @@ const discoverWorkspaceDirs = async (
|
|
|
112
127
|
const dirs: string[] = [];
|
|
113
128
|
|
|
114
129
|
for (const pattern of patterns) {
|
|
130
|
+
if (!isSupportedWorkspacePattern(pattern)) {
|
|
131
|
+
throw new Error(unsupportedWorkspacePatternError(pattern));
|
|
132
|
+
}
|
|
133
|
+
|
|
115
134
|
if (pattern.endsWith('/*')) {
|
|
116
135
|
const parent = join(repoRoot, pattern.slice(0, -2));
|
|
117
136
|
let names: string[] = [];
|
|
@@ -145,12 +164,6 @@ const discoverWorkspaceDirs = async (
|
|
|
145
164
|
continue;
|
|
146
165
|
}
|
|
147
166
|
|
|
148
|
-
if (hasWorkspaceGlobSyntax(pattern)) {
|
|
149
|
-
throw new Error(
|
|
150
|
-
`Unsupported workspace pattern '${pattern}'. The release check supports exact workspace paths and one-level '/*' globs.`
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
167
|
throw new Error(
|
|
155
168
|
`Workspace pattern '${pattern}' did not resolve to a package.json`
|
|
156
169
|
);
|
|
@@ -190,6 +203,126 @@ export const discoverWorkspaces = async (
|
|
|
190
203
|
);
|
|
191
204
|
};
|
|
192
205
|
|
|
206
|
+
interface BaseWorkspaceDiscovery {
|
|
207
|
+
readonly error?: string;
|
|
208
|
+
readonly workspaces: readonly WorkspaceInfo[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const baseWorkspaceReadError = (baseRef: string): string =>
|
|
212
|
+
`Release check could not read the base workspace inventory from '${baseRef}'. Fetch or provide a valid --base-ref before checking package routes.`;
|
|
213
|
+
|
|
214
|
+
const discoverBaseWorkspaces = (
|
|
215
|
+
repoRoot: string,
|
|
216
|
+
baseRef: string | undefined
|
|
217
|
+
): BaseWorkspaceDiscovery => {
|
|
218
|
+
if (baseRef === undefined) {
|
|
219
|
+
return { workspaces: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const root = Bun.spawnSync({
|
|
223
|
+
cmd: ['git', 'show', `${baseRef}:package.json`],
|
|
224
|
+
cwd: repoRoot,
|
|
225
|
+
stderr: 'pipe',
|
|
226
|
+
stdout: 'pipe',
|
|
227
|
+
});
|
|
228
|
+
if (root.exitCode !== 0) {
|
|
229
|
+
return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
|
|
230
|
+
}
|
|
231
|
+
const workspacePatterns = (
|
|
232
|
+
JSON.parse(root.stdout.toString()) as PackageJson
|
|
233
|
+
).workspaces?.map(normalizeWorkspacePattern);
|
|
234
|
+
if (!workspacePatterns || workspacePatterns.length === 0) {
|
|
235
|
+
return { workspaces: [] };
|
|
236
|
+
}
|
|
237
|
+
const unsupportedPattern = workspacePatterns.find(
|
|
238
|
+
(pattern) => !isSupportedWorkspacePattern(pattern)
|
|
239
|
+
);
|
|
240
|
+
if (unsupportedPattern) {
|
|
241
|
+
return {
|
|
242
|
+
error: unsupportedWorkspacePatternError(unsupportedPattern),
|
|
243
|
+
workspaces: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const listed = Bun.spawnSync({
|
|
248
|
+
cmd: ['git', 'ls-tree', '-r', '--name-only', baseRef],
|
|
249
|
+
cwd: repoRoot,
|
|
250
|
+
stderr: 'pipe',
|
|
251
|
+
stdout: 'pipe',
|
|
252
|
+
});
|
|
253
|
+
if (listed.exitCode !== 0) {
|
|
254
|
+
return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const workspaces: WorkspaceInfo[] = [];
|
|
258
|
+
for (const relativePath of listed.stdout.toString().split(/\r?\n/u)) {
|
|
259
|
+
if (
|
|
260
|
+
!relativePath.endsWith('/package.json') &&
|
|
261
|
+
relativePath !== 'package.json'
|
|
262
|
+
) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const workspacePath =
|
|
266
|
+
relativePath === 'package.json'
|
|
267
|
+
? '.'
|
|
268
|
+
: normalizePath(relativePath.replace(/\/package\.json$/u, ''));
|
|
269
|
+
const isWorkspace = workspacePatterns.some(
|
|
270
|
+
(pattern) =>
|
|
271
|
+
pattern === workspacePath ||
|
|
272
|
+
(pattern.endsWith('/*') &&
|
|
273
|
+
workspacePath.startsWith(pattern.slice(0, -1)) &&
|
|
274
|
+
!workspacePath.slice(pattern.length - 1).includes('/'))
|
|
275
|
+
);
|
|
276
|
+
if (!isWorkspace) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const shown = Bun.spawnSync({
|
|
280
|
+
cmd: ['git', 'show', `${baseRef}:${relativePath}`],
|
|
281
|
+
cwd: repoRoot,
|
|
282
|
+
stderr: 'pipe',
|
|
283
|
+
stdout: 'pipe',
|
|
284
|
+
});
|
|
285
|
+
if (shown.exitCode !== 0) {
|
|
286
|
+
return { error: baseWorkspaceReadError(baseRef), workspaces: [] };
|
|
287
|
+
}
|
|
288
|
+
const pkg = JSON.parse(shown.stdout.toString()) as PackageJson;
|
|
289
|
+
if (!pkg.name) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
workspaces.push({
|
|
293
|
+
isPrivate: pkg.private === true,
|
|
294
|
+
name: pkg.name,
|
|
295
|
+
relativePath: workspacePath,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
workspaces: workspaces.toSorted((left, right) =>
|
|
300
|
+
left.name.localeCompare(right.name)
|
|
301
|
+
),
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const resolveBaseWorkspaceInput = (
|
|
306
|
+
input: ReleaseCheckInput
|
|
307
|
+
): BaseWorkspaceDiscovery => {
|
|
308
|
+
if (input.baseWorkspaces !== undefined) {
|
|
309
|
+
return {
|
|
310
|
+
...(input.baseWorkspaceError === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { error: input.baseWorkspaceError }),
|
|
313
|
+
workspaces: input.baseWorkspaces,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const discovered = discoverBaseWorkspaces(input.repoRoot, input.baseRef);
|
|
318
|
+
const error = input.baseWorkspaceError ?? discovered.error;
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
...(error === undefined ? {} : { error }),
|
|
322
|
+
workspaces: discovered.workspaces,
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
193
326
|
const isPublishableOnTrailsWorkspace = (workspace: WorkspaceInfo): boolean =>
|
|
194
327
|
!workspace.isPrivate && workspace.name.startsWith('@ontrails/');
|
|
195
328
|
|
|
@@ -303,6 +436,84 @@ const parseChangesetPackages = (content: string): readonly string[] => {
|
|
|
303
436
|
});
|
|
304
437
|
};
|
|
305
438
|
|
|
439
|
+
const readBaseChangeset = (
|
|
440
|
+
repoRoot: string,
|
|
441
|
+
baseRef: string | undefined,
|
|
442
|
+
path: string
|
|
443
|
+
): string | null => {
|
|
444
|
+
if (baseRef === undefined) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const result = Bun.spawnSync({
|
|
449
|
+
cmd: ['git', 'show', `${baseRef}:${path}`],
|
|
450
|
+
cwd: repoRoot,
|
|
451
|
+
stderr: 'pipe',
|
|
452
|
+
stdout: 'pipe',
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return result.exitCode === 0 ? result.stdout.toString() : null;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const removeChangesetPackageRows = (
|
|
459
|
+
content: string,
|
|
460
|
+
packages: ReadonlySet<string>
|
|
461
|
+
): string => {
|
|
462
|
+
let insideFrontmatter = false;
|
|
463
|
+
|
|
464
|
+
return content
|
|
465
|
+
.split('\n')
|
|
466
|
+
.filter((line, index) => {
|
|
467
|
+
const normalizedLine = line.replace(/\r$/u, '');
|
|
468
|
+
|
|
469
|
+
if (normalizedLine === '---') {
|
|
470
|
+
insideFrontmatter = index === 0;
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!insideFrontmatter) {
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const packageName = normalizedLine.match(CHANGESET_PACKAGE_PATTERN)?.[1];
|
|
479
|
+
return packageName === undefined || !packages.has(packageName);
|
|
480
|
+
})
|
|
481
|
+
.join('\n');
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const findRetiredPackageChangesetCleanups = (
|
|
485
|
+
changedChangesetPaths: readonly string[],
|
|
486
|
+
input: ReleaseCheckInput
|
|
487
|
+
): readonly string[] => {
|
|
488
|
+
const workspaceNames = new Set(
|
|
489
|
+
input.workspaces.map((workspace) => workspace.name)
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
return changedChangesetPaths.filter((path) => {
|
|
493
|
+
const absolutePath = join(input.repoRoot, path);
|
|
494
|
+
if (!existsSync(absolutePath)) {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const baseContent = readBaseChangeset(input.repoRoot, input.baseRef, path);
|
|
499
|
+
if (baseContent === null) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const retiredPackages = new Set(
|
|
504
|
+
parseChangesetPackages(baseContent).filter(
|
|
505
|
+
(packageName) => !workspaceNames.has(packageName)
|
|
506
|
+
)
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
retiredPackages.size > 0 &&
|
|
511
|
+
removeChangesetPackageRows(baseContent, retiredPackages) ===
|
|
512
|
+
readFileSync(absolutePath, 'utf8')
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
|
|
306
517
|
const findChangedChangesetPaths = (
|
|
307
518
|
changedFiles: readonly string[]
|
|
308
519
|
): readonly string[] =>
|
|
@@ -380,9 +591,45 @@ const findMatchedRuleIds = (input: ReleaseCheckInput): readonly string[] => {
|
|
|
380
591
|
.toSorted();
|
|
381
592
|
};
|
|
382
593
|
|
|
594
|
+
const findPackageRouteRuleErrors = ({
|
|
595
|
+
coveredPackages,
|
|
596
|
+
packageRoute,
|
|
597
|
+
requiresPackageRoute,
|
|
598
|
+
}: {
|
|
599
|
+
readonly coveredPackages: readonly string[];
|
|
600
|
+
readonly packageRoute: ReturnType<typeof findPackageRouteReleaseFacts>;
|
|
601
|
+
readonly requiresPackageRoute: boolean;
|
|
602
|
+
}): readonly string[] => {
|
|
603
|
+
if (!requiresPackageRoute) {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const errors = packageRoute.diagnostics.map(
|
|
608
|
+
(diagnostic) => diagnostic.message
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
const uncoveredIntents = packageRoute.intents.filter(
|
|
612
|
+
(intent) =>
|
|
613
|
+
!intent.eligiblePackages.some((packageName) =>
|
|
614
|
+
coveredPackages.includes(packageName)
|
|
615
|
+
)
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
if (uncoveredIntents.length > 0) {
|
|
619
|
+
errors.push(
|
|
620
|
+
`Public package route changesets must cover a surviving owner: ${uncoveredIntents
|
|
621
|
+
.map((intent) => intent.sourcePackage)
|
|
622
|
+
.join(', ')}`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return errors;
|
|
627
|
+
};
|
|
628
|
+
|
|
383
629
|
export const checkReleaseRules = (
|
|
384
630
|
input: ReleaseCheckInput
|
|
385
631
|
): ReleaseCheckResult => {
|
|
632
|
+
const baseWorkspaceInput = resolveBaseWorkspaceInput(input);
|
|
386
633
|
const noReleaseOverride =
|
|
387
634
|
input.noReleaseOverride === true || input.releaseNone === true;
|
|
388
635
|
const affectedPackages = findAffectedPackages(
|
|
@@ -394,11 +641,18 @@ export const checkReleaseRules = (
|
|
|
394
641
|
input.changedFiles,
|
|
395
642
|
input.repoRoot
|
|
396
643
|
);
|
|
644
|
+
const retiredPackageChangesetCleanups = new Set(
|
|
645
|
+
findRetiredPackageChangesetCleanups(changedChangesets, input)
|
|
646
|
+
);
|
|
397
647
|
const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
|
|
398
648
|
const coveredPackages = [
|
|
399
649
|
...new Set(changesets.flatMap((changeset) => changeset.packages)),
|
|
400
650
|
].toSorted();
|
|
401
651
|
const contractFacts = findGateContractFacts(input);
|
|
652
|
+
const packageRoute = findPackageRouteReleaseFacts({
|
|
653
|
+
baseWorkspaces: baseWorkspaceInput.workspaces,
|
|
654
|
+
workspaces: input.workspaces,
|
|
655
|
+
});
|
|
402
656
|
const versionRelease = isVersionReleaseChangeSet(
|
|
403
657
|
input.changedFiles,
|
|
404
658
|
input.workspaces,
|
|
@@ -411,10 +665,15 @@ export const checkReleaseRules = (
|
|
|
411
665
|
(fact) => !isContractFactCovered(fact, coveredPackages)
|
|
412
666
|
);
|
|
413
667
|
const hasReleaseFacts =
|
|
414
|
-
affectedPackages.length > 0 ||
|
|
668
|
+
affectedPackages.length > 0 ||
|
|
669
|
+
contractFacts.length > 0 ||
|
|
670
|
+
packageRoute.facts.length > 0 ||
|
|
671
|
+
versionRelease;
|
|
415
672
|
const activePackageChangesetsWithoutReleaseFacts =
|
|
416
673
|
activeChangedChangesets.length > 0 && !hasReleaseFacts
|
|
417
|
-
? activeChangedChangesets
|
|
674
|
+
? activeChangedChangesets.filter(
|
|
675
|
+
(path) => !retiredPackageChangesetCleanups.has(path)
|
|
676
|
+
)
|
|
418
677
|
: [];
|
|
419
678
|
const matchedRuleIds = findMatchedRuleIds(input);
|
|
420
679
|
const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
|
|
@@ -422,8 +681,16 @@ export const checkReleaseRules = (
|
|
|
422
681
|
input,
|
|
423
682
|
'public-trail-contract'
|
|
424
683
|
);
|
|
684
|
+
const requiresPackageRoute = ruleMatchesFactType(
|
|
685
|
+
input,
|
|
686
|
+
'public-package-route'
|
|
687
|
+
);
|
|
425
688
|
const errors: string[] = [];
|
|
426
689
|
|
|
690
|
+
if (baseWorkspaceInput.error) {
|
|
691
|
+
errors.push(baseWorkspaceInput.error);
|
|
692
|
+
}
|
|
693
|
+
|
|
427
694
|
if (noReleaseOverride && changedChangesets.length > 0) {
|
|
428
695
|
errors.push(
|
|
429
696
|
'`release:none` conflicts with changed changeset files. Remove the label or the changeset.'
|
|
@@ -447,6 +714,14 @@ export const checkReleaseRules = (
|
|
|
447
714
|
);
|
|
448
715
|
}
|
|
449
716
|
|
|
717
|
+
errors.push(
|
|
718
|
+
...findPackageRouteRuleErrors({
|
|
719
|
+
coveredPackages,
|
|
720
|
+
packageRoute,
|
|
721
|
+
requiresPackageRoute,
|
|
722
|
+
})
|
|
723
|
+
);
|
|
724
|
+
|
|
450
725
|
if (
|
|
451
726
|
!noReleaseOverride &&
|
|
452
727
|
!versionRelease &&
|
|
@@ -469,6 +744,7 @@ export const checkReleaseRules = (
|
|
|
469
744
|
errors,
|
|
470
745
|
matchedRuleIds,
|
|
471
746
|
noReleaseOverride,
|
|
747
|
+
packageRouteFacts: packageRoute.facts,
|
|
472
748
|
passed: errors.length === 0,
|
|
473
749
|
releaseNone: noReleaseOverride,
|
|
474
750
|
uncoveredContractFacts,
|
|
@@ -516,6 +792,17 @@ const readLocalChangedFiles = (
|
|
|
516
792
|
return parseChangedFilesOutput(result.stdout.toString());
|
|
517
793
|
};
|
|
518
794
|
|
|
795
|
+
const hasGitRef = (repoRoot: string, ref: string): boolean => {
|
|
796
|
+
const result = Bun.spawnSync({
|
|
797
|
+
cmd: ['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
798
|
+
cwd: repoRoot,
|
|
799
|
+
stderr: 'pipe',
|
|
800
|
+
stdout: 'pipe',
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
return result.exitCode === 0;
|
|
804
|
+
};
|
|
805
|
+
|
|
519
806
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
520
807
|
typeof value === 'object' && value !== null;
|
|
521
808
|
|
|
@@ -769,9 +1056,16 @@ export const runReleaseCheck = async (
|
|
|
769
1056
|
options: RunReleaseCheckOptions
|
|
770
1057
|
): Promise<ReleaseCheckReport> => {
|
|
771
1058
|
const workspaces = await discoverWorkspaces(options.repoRoot);
|
|
1059
|
+
const changedFilesBaseError =
|
|
1060
|
+
options.changedFilesPath !== undefined && options.baseRef === undefined
|
|
1061
|
+
? 'Release check requires --base-ref when --changed-files is used.'
|
|
1062
|
+
: undefined;
|
|
772
1063
|
const baseRef =
|
|
773
1064
|
options.baseRef ??
|
|
774
|
-
(options.changedFilesPath === undefined
|
|
1065
|
+
(options.changedFilesPath === undefined &&
|
|
1066
|
+
(workspaces.length > 0 || hasGitRef(options.repoRoot, 'origin/main'))
|
|
1067
|
+
? 'origin/main'
|
|
1068
|
+
: undefined);
|
|
775
1069
|
let changedFiles: readonly string[];
|
|
776
1070
|
|
|
777
1071
|
if (options.changedFilesPath !== undefined) {
|
|
@@ -792,8 +1086,16 @@ export const runReleaseCheck = async (
|
|
|
792
1086
|
env: options.env,
|
|
793
1087
|
repoRoot: options.repoRoot,
|
|
794
1088
|
});
|
|
1089
|
+
const baseWorkspaceDiscovery = discoverBaseWorkspaces(
|
|
1090
|
+
options.repoRoot,
|
|
1091
|
+
baseRef
|
|
1092
|
+
);
|
|
1093
|
+
const baseWorkspaceError =
|
|
1094
|
+
changedFilesBaseError ?? baseWorkspaceDiscovery.error;
|
|
795
1095
|
const result = checkReleaseRules({
|
|
796
1096
|
...(baseRef === undefined ? {} : { baseRef }),
|
|
1097
|
+
...(baseWorkspaceError === undefined ? {} : { baseWorkspaceError }),
|
|
1098
|
+
baseWorkspaces: baseWorkspaceDiscovery.workspaces,
|
|
797
1099
|
changedFiles,
|
|
798
1100
|
...(loadedConfig.config === undefined
|
|
799
1101
|
? {}
|
package/src/release/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
export const releaseFactTypeValues = [
|
|
4
4
|
'package-content',
|
|
5
|
+
'public-package-route',
|
|
5
6
|
'public-trail-contract',
|
|
6
7
|
] as const;
|
|
7
8
|
|
|
@@ -31,6 +32,15 @@ export type ReleaseRule = z.output<typeof releaseRuleSchema>;
|
|
|
31
32
|
export type ReleaseRuleInput = z.input<typeof releaseRuleSchema>;
|
|
32
33
|
|
|
33
34
|
export const defaultReleaseRules = [
|
|
35
|
+
{
|
|
36
|
+
description:
|
|
37
|
+
'Public package removals require an exact governed Regrade route or classified multi-owner fold plus branch-local release intent.',
|
|
38
|
+
enabled: true,
|
|
39
|
+
facts: ['public-package-route'],
|
|
40
|
+
id: 'public-package-route-requires-regrade',
|
|
41
|
+
intent: ['changeset'],
|
|
42
|
+
severity: 'error',
|
|
43
|
+
},
|
|
34
44
|
{
|
|
35
45
|
description:
|
|
36
46
|
'Publishable package content changes require positive release intent.',
|
package/src/release/index.ts
CHANGED
|
@@ -31,6 +31,11 @@ export {
|
|
|
31
31
|
type ContractReleaseFactInput,
|
|
32
32
|
type ContractSourceSnapshot,
|
|
33
33
|
} from './contract-facts.js';
|
|
34
|
+
export {
|
|
35
|
+
findPackageRouteReleaseFacts,
|
|
36
|
+
type PackageRouteReleaseDiagnostic,
|
|
37
|
+
type PackageRouteReleaseFact,
|
|
38
|
+
} from './package-route-facts.js';
|
|
34
39
|
export {
|
|
35
40
|
defaultReleaseConfig,
|
|
36
41
|
defaultReleaseRules,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { listGovernedVocabularyTransitions } from '@ontrails/warden';
|
|
2
|
+
|
|
3
|
+
import type { WorkspaceInfo } from './check.js';
|
|
4
|
+
|
|
5
|
+
export interface PackageRouteReleaseFact {
|
|
6
|
+
readonly kind: 'classified' | 'single';
|
|
7
|
+
readonly sourcePackage: string;
|
|
8
|
+
readonly targetPackage?: string;
|
|
9
|
+
readonly transitionId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PackageRouteReleaseDiagnostic {
|
|
13
|
+
readonly code:
|
|
14
|
+
| 'missing-governed-package-route'
|
|
15
|
+
| 'missing-governed-package-route-target';
|
|
16
|
+
readonly message: string;
|
|
17
|
+
readonly sourcePackage: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PackageRouteReleaseIntent {
|
|
21
|
+
readonly eligiblePackages: readonly string[];
|
|
22
|
+
readonly sourcePackage: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isPublishableTrailsPackage = (workspace: WorkspaceInfo): boolean =>
|
|
26
|
+
!workspace.isPrivate && workspace.name.startsWith('@ontrails/');
|
|
27
|
+
|
|
28
|
+
const findPackageRouteTransition = (
|
|
29
|
+
transitions: ReturnType<typeof listGovernedVocabularyTransitions>,
|
|
30
|
+
sourcePackage: string
|
|
31
|
+
) => {
|
|
32
|
+
const direct = transitions.find(
|
|
33
|
+
(transition) => transition.from === sourcePackage
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (direct) {
|
|
37
|
+
return { target: direct.target, transition: direct };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const transition of transitions) {
|
|
41
|
+
const moduleSpecifierRoute = transition.stringLiteralRenames.find(
|
|
42
|
+
(route) =>
|
|
43
|
+
route.from === sourcePackage &&
|
|
44
|
+
route.moduleSpecifier?.targetPackage !== undefined
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (moduleSpecifierRoute?.moduleSpecifier?.targetPackage) {
|
|
48
|
+
return {
|
|
49
|
+
target: {
|
|
50
|
+
kind: 'single' as const,
|
|
51
|
+
to: moduleSpecifierRoute.moduleSpecifier.targetPackage,
|
|
52
|
+
},
|
|
53
|
+
transition,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const findPackageRouteReleaseFacts = ({
|
|
62
|
+
baseWorkspaces,
|
|
63
|
+
workspaces,
|
|
64
|
+
}: {
|
|
65
|
+
readonly baseWorkspaces: readonly WorkspaceInfo[];
|
|
66
|
+
readonly workspaces: readonly WorkspaceInfo[];
|
|
67
|
+
}): {
|
|
68
|
+
readonly diagnostics: readonly PackageRouteReleaseDiagnostic[];
|
|
69
|
+
readonly facts: readonly PackageRouteReleaseFact[];
|
|
70
|
+
readonly intents: readonly PackageRouteReleaseIntent[];
|
|
71
|
+
} => {
|
|
72
|
+
const currentPackages = new Set(
|
|
73
|
+
workspaces
|
|
74
|
+
.filter(isPublishableTrailsPackage)
|
|
75
|
+
.map((workspace) => workspace.name)
|
|
76
|
+
);
|
|
77
|
+
const removedPackages = baseWorkspaces
|
|
78
|
+
.filter(isPublishableTrailsPackage)
|
|
79
|
+
.map((workspace) => workspace.name)
|
|
80
|
+
.filter((name) => !currentPackages.has(name))
|
|
81
|
+
.toSorted();
|
|
82
|
+
const transitions = listGovernedVocabularyTransitions();
|
|
83
|
+
const diagnostics: PackageRouteReleaseDiagnostic[] = [];
|
|
84
|
+
const facts: PackageRouteReleaseFact[] = [];
|
|
85
|
+
const intents: PackageRouteReleaseIntent[] = [];
|
|
86
|
+
|
|
87
|
+
for (const sourcePackage of removedPackages) {
|
|
88
|
+
const resolvedRoute = findPackageRouteTransition(
|
|
89
|
+
transitions,
|
|
90
|
+
sourcePackage
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (!resolvedRoute) {
|
|
94
|
+
diagnostics.push({
|
|
95
|
+
code: 'missing-governed-package-route',
|
|
96
|
+
message: `Public package removal '${sourcePackage}' requires an exact governed Regrade route. Add the route to the governed transition registry, then run trails regrade plan/check.`,
|
|
97
|
+
sourcePackage,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { target, transition } = resolvedRoute;
|
|
103
|
+
|
|
104
|
+
if (target.kind === 'classified') {
|
|
105
|
+
const classifiedTarget = target;
|
|
106
|
+
const eligiblePackages = [...currentPackages]
|
|
107
|
+
.filter((packageName) =>
|
|
108
|
+
classifiedTarget.options.some(
|
|
109
|
+
(option) =>
|
|
110
|
+
option.to === packageName ||
|
|
111
|
+
option.to.startsWith(`${packageName}/`)
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
.toSorted();
|
|
115
|
+
facts.push({
|
|
116
|
+
kind: 'classified',
|
|
117
|
+
sourcePackage,
|
|
118
|
+
transitionId: transition.id,
|
|
119
|
+
});
|
|
120
|
+
intents.push({ eligiblePackages, sourcePackage });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!currentPackages.has(target.to)) {
|
|
125
|
+
diagnostics.push({
|
|
126
|
+
code: 'missing-governed-package-route-target',
|
|
127
|
+
message: `Governed Regrade route '${transition.id}' maps '${sourcePackage}' to '${target.to}', but that publishable target package is absent. Add the target package before applying the route, or use a classified transition with an explicit non-migratable reason for a multi-owner fold.`,
|
|
128
|
+
sourcePackage,
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
facts.push({
|
|
134
|
+
kind: 'single',
|
|
135
|
+
sourcePackage,
|
|
136
|
+
targetPackage: target.to,
|
|
137
|
+
transitionId: transition.id,
|
|
138
|
+
});
|
|
139
|
+
intents.push({
|
|
140
|
+
eligiblePackages: [target.to],
|
|
141
|
+
sourcePackage,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { diagnostics, facts, intents };
|
|
146
|
+
};
|
|
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
|
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
renderPublicSurfaceError,
|
|
16
16
|
Result,
|
|
17
17
|
ValidationError,
|
|
18
18
|
} from '@ontrails/core';
|
|
@@ -138,9 +138,9 @@ export const runCompletionsInstall = async (
|
|
|
138
138
|
|
|
139
139
|
const handleCliError = (error: unknown): void => {
|
|
140
140
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
141
|
-
const
|
|
142
|
-
process.stderr.write(`Error: ${
|
|
143
|
-
process.exit(
|
|
141
|
+
const rendering = renderPublicSurfaceError('cli', err);
|
|
142
|
+
process.stderr.write(`Error: ${rendering.message}\n`);
|
|
143
|
+
process.exit(rendering.code);
|
|
144
144
|
};
|
|
145
145
|
|
|
146
146
|
const findCompletionsCommand = (program: Command): Command | undefined =>
|
package/src/run-trace.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* intrinsic tracing pipeline in `@ontrails/core` records every trail-,
|
|
6
6
|
* span-, signal-, and activation-level event during the invocation. After
|
|
7
7
|
* the trail completes (success or failure), the records are rendered as a
|
|
8
|
-
* tree to stderr via `renderTraceTree` from `@ontrails/
|
|
8
|
+
* tree to stderr via `renderTraceTree` from `@ontrails/observability`. Under
|
|
9
9
|
* `--json`, the structured `TraceRecord[]` is also emitted on stdout as
|
|
10
10
|
* the `tracing` field of a Result envelope.
|
|
11
11
|
*
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { getTraceSink, registerTraceSink } from '@ontrails/core';
|
|
27
27
|
import type { TraceRecord, TraceSink } from '@ontrails/core';
|
|
28
|
-
import { createMemorySink, renderTraceTree } from '@ontrails/
|
|
29
|
-
import type { MemoryTraceSink } from '@ontrails/
|
|
28
|
+
import { createMemorySink, renderTraceTree } from '@ontrails/observability';
|
|
29
|
+
import type { MemoryTraceSink } from '@ontrails/observability';
|
|
30
30
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// Argv detection
|
|
@@ -166,7 +166,7 @@ const errorFromUnknown = (
|
|
|
166
166
|
* On success, the inner value is taken straight from the trail's
|
|
167
167
|
* `Result.ok(...)` payload. On failure the envelope captures the error's
|
|
168
168
|
* `name` and `message` -- both safe to serialize and consistent with how
|
|
169
|
-
* other Trails surfaces
|
|
169
|
+
* other Trails surfaces render errors.
|
|
170
170
|
*/
|
|
171
171
|
export const buildTraceJsonEnvelope = (
|
|
172
172
|
result: ResultLike,
|