breakage 0.8.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -47
- package/dist/{breakage-B7dJsgcu.js → breakage-DIY49fzP.js} +331 -217
- package/dist/breakage-DIY49fzP.js.map +1 -0
- package/dist/breakage.d.ts +111 -55
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +78 -80
- package/dist/breakage.x.js.map +1 -1
- package/dist/changesets.d.ts +8 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +27 -0
- package/dist/changesets.js.map +1 -0
- package/dist/release-BsdIDT4g.js +69 -0
- package/dist/release-BsdIDT4g.js.map +1 -0
- package/dist/release-D47BqRuB.d.ts +40 -0
- package/dist/release-D47BqRuB.d.ts.map +1 -0
- package/npm.md +55 -33
- package/package.json +45 -6
- package/src/breakage.ts +125 -73
- package/src/breakage.x.ts +148 -127
- package/src/changesets.ts +51 -0
- package/src/config.ts +128 -0
- package/src/npm/check.ts +51 -50
- package/src/npm/cli.ts +26 -22
- package/src/npm/config.ts +38 -6
- package/src/npm/environment.ts +10 -10
- package/src/npm/release.ts +9 -25
- package/src/npm/types.ts +5 -45
- package/src/release.ts +153 -0
- package/src/{npm/command.ts → task.ts} +2 -0
- package/dist/breakage-B7dJsgcu.js.map +0 -1
- package/dist/breakage.$configPath.schema.json +0 -33
- package/dist/breakage.help.schema.json +0 -6
- package/dist/breakage.main.schema.json +0 -33
- package/dist/breakage.npm.check.schema.json +0 -16
- package/dist/breakage.npm.help.schema.json +0 -6
- package/dist/breakage.npm.schema.json +0 -6
- package/dist/breakage.npm.test.$peer.$range.schema.json +0 -16
- package/dist/breakage.npm.test.schema.json +0 -16
- package/dist/breakage.prelude.$configPath.schema.json +0 -31
- package/dist/breakage.prelude.schema.json +0 -31
- package/dist/breakage.schema.schema.json +0 -10
package/src/npm/check.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
resolveReleaseDelta,
|
|
5
|
+
versionDelta,
|
|
6
|
+
type ReleaseDeltaContext,
|
|
7
|
+
type VersionTransition,
|
|
8
|
+
} from "../release.ts"
|
|
9
|
+
import { command, errorMessage } from "../task.ts"
|
|
6
10
|
import { validateConfig, validatePeerRanges } from "./config.ts"
|
|
7
11
|
import {
|
|
8
12
|
checkCandidatePeers,
|
|
@@ -20,13 +24,12 @@ import {
|
|
|
20
24
|
normalizeWorkspacePeers,
|
|
21
25
|
readManifest,
|
|
22
26
|
} from "./manifest.ts"
|
|
23
|
-
import {
|
|
27
|
+
import { releaseVersion, previousManifest, supportChanges } from "./release.ts"
|
|
24
28
|
import type {
|
|
25
29
|
NpmConfig,
|
|
26
30
|
NpmReport,
|
|
27
31
|
NpmRepresentativeResult,
|
|
28
32
|
NpmTestSelection,
|
|
29
|
-
ReleaseIntent,
|
|
30
33
|
} from "./types.ts"
|
|
31
34
|
|
|
32
35
|
async function checkSupport(
|
|
@@ -44,36 +47,28 @@ async function checkSupport(
|
|
|
44
47
|
const changes = supportChanges(previous, manifest)
|
|
45
48
|
if (!changes.length)
|
|
46
49
|
return { status: `unchanged`, previousVersion: previous.version, changes }
|
|
47
|
-
const evidence =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const evidence = config.release
|
|
51
|
+
? await resolveReleaseDelta(config.release, {
|
|
52
|
+
packageName: manifest.name,
|
|
53
|
+
packageDirectory: base,
|
|
54
|
+
currentVersion: manifest.version,
|
|
55
|
+
baseline: { version: previous.version },
|
|
56
|
+
})
|
|
57
|
+
: undefined
|
|
58
|
+
if (evidence === undefined)
|
|
53
59
|
return {
|
|
54
60
|
status: `end-of-support`,
|
|
55
61
|
previousVersion: previous.version,
|
|
56
62
|
changes,
|
|
57
63
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
delta: semver.diff(previous.version, evidence.version) ?? `none`,
|
|
65
|
-
}
|
|
66
|
-
const certified = config.certifyBreaking
|
|
67
|
-
? await config.certifyBreaking({
|
|
68
|
-
previousVersion: previous.version,
|
|
69
|
-
release,
|
|
70
|
-
changes,
|
|
71
|
-
})
|
|
72
|
-
: false
|
|
73
|
-
if (typeof certified !== `boolean`)
|
|
74
|
-
throw new Error(`certifyBreaking must return a boolean.`)
|
|
64
|
+
const release = versionDelta(
|
|
65
|
+
evidence.previousVersion,
|
|
66
|
+
evidence.version,
|
|
67
|
+
config.release?.policy,
|
|
68
|
+
)
|
|
69
|
+
|
|
75
70
|
return {
|
|
76
|
-
status:
|
|
71
|
+
status: release.breaking ? `breaking-release` : `end-of-support`,
|
|
77
72
|
previousVersion: previous.version,
|
|
78
73
|
changes,
|
|
79
74
|
release,
|
|
@@ -99,26 +94,26 @@ async function checkLocalReleases(
|
|
|
99
94
|
const current = await readManifest(path.join(directory, `package.json`))
|
|
100
95
|
if (current.name !== peer)
|
|
101
96
|
throw new Error(`Local peer identity mismatch for ${peer}.`)
|
|
102
|
-
const release = await
|
|
97
|
+
const release = await releaseVersion(config, {
|
|
103
98
|
packageName: peer,
|
|
104
99
|
packageDirectory: directory,
|
|
105
100
|
currentVersion: current.version,
|
|
106
101
|
})
|
|
107
|
-
if (release
|
|
108
|
-
if (!matches(release
|
|
102
|
+
if (release === undefined) return []
|
|
103
|
+
if (!matches(release, range))
|
|
109
104
|
return [
|
|
110
|
-
`Declared support for ${peer}: ${range} does not include its planned local release ${release
|
|
105
|
+
`Declared support for ${peer}: ${range} does not include its planned local release ${release}. Update the peer range to include the intended version.`,
|
|
111
106
|
]
|
|
112
107
|
if (
|
|
113
108
|
!representatives.some(
|
|
114
109
|
(result) =>
|
|
115
110
|
result.peers[peer]?.source === `candidate` &&
|
|
116
|
-
result.peers[peer]?.version === release
|
|
111
|
+
result.peers[peer]?.version === release &&
|
|
117
112
|
result.suites.some((suite) => suite.peer === peer),
|
|
118
113
|
)
|
|
119
114
|
)
|
|
120
115
|
return [
|
|
121
|
-
`No coordinated representative covers planned local peer ${peer}@${release
|
|
116
|
+
`No coordinated representative covers planned local peer ${peer}@${release}. Enroll ${peer} with breakage.candidates and configure its relationship suite.`,
|
|
122
117
|
]
|
|
123
118
|
return []
|
|
124
119
|
} catch (error) {
|
|
@@ -297,24 +292,30 @@ async function run(
|
|
|
297
292
|
)
|
|
298
293
|
eras(selection.range)
|
|
299
294
|
}
|
|
300
|
-
// One piece of release evidence per package per invocation, including failures.
|
|
301
|
-
const releases = new Map<string, Promise<
|
|
302
|
-
const
|
|
295
|
+
// One piece of release evidence per package and baseline per invocation, including failures.
|
|
296
|
+
const releases = new Map<string, Promise<VersionTransition | undefined>>()
|
|
297
|
+
const resolveDelta = (
|
|
298
|
+
context: ReleaseDeltaContext,
|
|
299
|
+
): Promise<VersionTransition | undefined> => {
|
|
300
|
+
const key = JSON.stringify([
|
|
301
|
+
context.packageDirectory,
|
|
302
|
+
context.currentVersion,
|
|
303
|
+
context.baseline,
|
|
304
|
+
])
|
|
305
|
+
let pending = releases.get(key)
|
|
306
|
+
if (!pending) {
|
|
307
|
+
pending = resolveReleaseDelta(config.release!, context)
|
|
308
|
+
releases.set(key, pending)
|
|
309
|
+
}
|
|
310
|
+
return pending
|
|
311
|
+
}
|
|
303
312
|
const effective: NpmConfig = {
|
|
304
313
|
...config,
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
pending = Promise.resolve().then(() =>
|
|
309
|
-
configuredHook
|
|
310
|
-
? configuredHook(context)
|
|
311
|
-
: ({ status: `none` } as const),
|
|
312
|
-
)
|
|
313
|
-
releases.set(context.packageDirectory, pending)
|
|
314
|
-
}
|
|
315
|
-
return pending
|
|
316
|
-
},
|
|
314
|
+
...(config.release
|
|
315
|
+
? { release: { ...config.release, resolveDelta } }
|
|
316
|
+
: {}),
|
|
317
317
|
}
|
|
318
|
+
|
|
318
319
|
const directories = await discover(config, base)
|
|
319
320
|
const [support, representatives] = await Promise.all([
|
|
320
321
|
selection
|
package/src/npm/cli.ts
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
|
-
import { existsSync } from "node:fs"
|
|
2
|
-
import { readFile } from "node:fs/promises"
|
|
3
1
|
import path from "node:path"
|
|
4
|
-
import { pathToFileURL } from "node:url"
|
|
5
2
|
|
|
6
3
|
import { type } from "arktype"
|
|
7
4
|
import type { OptionsGroup } from "comline"
|
|
8
5
|
import { options, parseBooleanOption } from "comline"
|
|
9
6
|
|
|
7
|
+
import { configOptions, defaultConfig, loadConfig } from "../config.ts"
|
|
8
|
+
|
|
10
9
|
import { formatNpmReport, npmCheck, npmTest } from "./check.ts"
|
|
11
|
-
import { errorMessage } from "
|
|
10
|
+
import { errorMessage } from "../task.ts"
|
|
12
11
|
import { validateConfig } from "./config.ts"
|
|
13
|
-
import { record } from "./manifest.ts"
|
|
14
12
|
import type { NpmTestSelection } from "./types.ts"
|
|
15
13
|
|
|
16
|
-
type CommonOptions = {
|
|
14
|
+
type CommonOptions = {
|
|
15
|
+
config?: string | undefined
|
|
16
|
+
json?: boolean | undefined
|
|
17
|
+
release?: import("../release.ts").ReleaseConfig
|
|
18
|
+
npm?: import("./types.ts").NpmConfig
|
|
19
|
+
}
|
|
17
20
|
type CheckOptions = CommonOptions & { coverageOnly?: boolean | undefined }
|
|
18
21
|
type TestOptions = CommonOptions & { representative?: string | undefined }
|
|
19
22
|
|
|
20
23
|
const common = {
|
|
24
|
+
release: configOptions.optionConfigs.release,
|
|
25
|
+
npm: configOptions.optionConfigs.npm,
|
|
21
26
|
config: {
|
|
27
|
+
schema: type(`string`),
|
|
22
28
|
required: false,
|
|
23
|
-
description: `Configuration file (defaults to breakage.config.ts
|
|
29
|
+
description: `Configuration file (defaults to breakage.config.ts).`,
|
|
24
30
|
example: `--config ./breakage.config.ts`,
|
|
25
31
|
completion: { fileSystem: `files` as const, repeatable: false },
|
|
26
32
|
},
|
|
27
33
|
json: {
|
|
34
|
+
schema: type(`boolean`),
|
|
28
35
|
required: false,
|
|
29
36
|
description: `Emit the structured result.`,
|
|
30
37
|
example: `--json`,
|
|
@@ -34,10 +41,17 @@ const common = {
|
|
|
34
41
|
|
|
35
42
|
export const npmCheckManual: OptionsGroup<CheckOptions> = options(
|
|
36
43
|
`Check npm peer compatibility and changes in declared support.`,
|
|
37
|
-
type({
|
|
44
|
+
type({
|
|
45
|
+
"config?": `string`,
|
|
46
|
+
"json?": `boolean`,
|
|
47
|
+
"coverageOnly?": `boolean`,
|
|
48
|
+
"release?": configOptions.optionsSchema.get(`release`),
|
|
49
|
+
"npm?": configOptions.optionsSchema.get(`npm`),
|
|
50
|
+
}),
|
|
38
51
|
{
|
|
39
52
|
...common,
|
|
40
53
|
coverageOnly: {
|
|
54
|
+
schema: type(`boolean`),
|
|
41
55
|
aliases: [`coverage-only`],
|
|
42
56
|
required: false,
|
|
43
57
|
description: `Audit coverage and support changes without executing assertions.`,
|
|
@@ -53,10 +67,13 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
|
|
|
53
67
|
"config?": `string`,
|
|
54
68
|
"json?": `boolean`,
|
|
55
69
|
"representative?": `string`,
|
|
70
|
+
"release?": configOptions.optionsSchema.get(`release`),
|
|
71
|
+
"npm?": configOptions.optionsSchema.get(`npm`),
|
|
56
72
|
}),
|
|
57
73
|
{
|
|
58
74
|
...common,
|
|
59
75
|
representative: {
|
|
76
|
+
schema: type(`string`),
|
|
60
77
|
required: false,
|
|
61
78
|
description: `Select an exact configured representative path relative to the package.`,
|
|
62
79
|
example: `--representative peer-environments/react-19`,
|
|
@@ -65,15 +82,6 @@ export const npmTestManual: OptionsGroup<TestOptions> = options(
|
|
|
65
82
|
},
|
|
66
83
|
)
|
|
67
84
|
|
|
68
|
-
export function defaultConfig(directory: string): string {
|
|
69
|
-
return path.join(
|
|
70
|
-
directory,
|
|
71
|
-
existsSync(path.join(directory, `breakage.config.ts`))
|
|
72
|
-
? `breakage.config.ts`
|
|
73
|
-
: `breakage.config.json`,
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
85
|
export async function runNpmCli(
|
|
78
86
|
action: `check` | `test`,
|
|
79
87
|
opts: CheckOptions & TestOptions,
|
|
@@ -81,11 +89,7 @@ export async function runNpmCli(
|
|
|
81
89
|
): Promise<number> {
|
|
82
90
|
try {
|
|
83
91
|
const filename = path.resolve(opts.config ?? defaultConfig(process.cwd()))
|
|
84
|
-
const
|
|
85
|
-
? JSON.parse(await readFile(filename, `utf8`))
|
|
86
|
-
: ((await import(pathToFileURL(filename).href)) as { default: unknown })
|
|
87
|
-
.default
|
|
88
|
-
const config = record(loaded) ? loaded.npm : undefined
|
|
92
|
+
const config = (await loadConfig(filename)).npm
|
|
89
93
|
validateConfig(config)
|
|
90
94
|
let selection: NpmTestSelection | undefined
|
|
91
95
|
if (action === `test`) {
|
package/src/npm/config.ts
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
+
import { type } from "arktype"
|
|
3
|
+
|
|
4
|
+
import { releaseSchema, rejectCertificationOptions } from "../release.ts"
|
|
2
5
|
|
|
3
6
|
import { eras, packageName, record } from "./manifest.ts"
|
|
4
7
|
import type { NpmConfig } from "./types.ts"
|
|
5
8
|
|
|
9
|
+
export const npmSuiteSchema = type({
|
|
10
|
+
"+": `reject`,
|
|
11
|
+
files: `string[]`,
|
|
12
|
+
command: `string`,
|
|
13
|
+
})
|
|
14
|
+
export const npmConfigSchema = type({
|
|
15
|
+
"+": `reject`,
|
|
16
|
+
"release?": releaseSchema,
|
|
17
|
+
"packageDirectory?": `string`,
|
|
18
|
+
artifact: `string`,
|
|
19
|
+
representatives: `string | string[]`,
|
|
20
|
+
lockfile: `string`,
|
|
21
|
+
prepareCommand: `string`,
|
|
22
|
+
"installCommand?": `string`,
|
|
23
|
+
suites: { "[string]": npmSuiteSchema },
|
|
24
|
+
"localPeers?": {
|
|
25
|
+
"[string]": { "+": `reject`, directory: `string`, artifact: `string` },
|
|
26
|
+
},
|
|
27
|
+
baseline: type({ "+": `reject`, firstRelease: `true` }).or({
|
|
28
|
+
"+": `reject`,
|
|
29
|
+
"tagPattern?": `string`,
|
|
30
|
+
"baselineFile?": `string`,
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
6
34
|
function nonempty(value: unknown): value is string {
|
|
7
35
|
return typeof value === `string` && value.trim().length > 0
|
|
8
36
|
}
|
|
9
37
|
|
|
10
38
|
export function validateConfig(value: unknown): asserts value is NpmConfig {
|
|
11
39
|
if (!record(value)) throw new Error(`Configuration must have an npm object.`)
|
|
12
|
-
|
|
40
|
+
rejectCertificationOptions(value)
|
|
41
|
+
npmConfigSchema.assert(value)
|
|
42
|
+
for (const key of [`artifact`, `lockfile`] as const) {
|
|
13
43
|
if (!nonempty(value[key]))
|
|
14
44
|
throw new Error(`npm.${key} must be a nonempty string.`)
|
|
15
45
|
}
|
|
@@ -19,7 +49,13 @@ export function validateConfig(value: unknown): asserts value is NpmConfig {
|
|
|
19
49
|
) {
|
|
20
50
|
throw new Error(`npm.lockfile must stay inside each representative.`)
|
|
21
51
|
}
|
|
22
|
-
|
|
52
|
+
if (!nonempty(value.prepareCommand))
|
|
53
|
+
throw new Error(
|
|
54
|
+
`npm.prepareCommand must be a nonempty shell command string.`,
|
|
55
|
+
)
|
|
56
|
+
if (value.installCommand !== undefined && !nonempty(value.installCommand))
|
|
57
|
+
throw new Error(`Invalid npm.installCommand.`)
|
|
58
|
+
for (const key of [`packageDirectory`] as const) {
|
|
23
59
|
if (value[key] !== undefined && !nonempty(value[key]))
|
|
24
60
|
throw new Error(`Invalid npm.${key}.`)
|
|
25
61
|
}
|
|
@@ -75,10 +111,6 @@ export function validateConfig(value: unknown): asserts value is NpmConfig {
|
|
|
75
111
|
}
|
|
76
112
|
}
|
|
77
113
|
}
|
|
78
|
-
for (const hook of [`releaseIntent`, `certifyBreaking`] as const) {
|
|
79
|
-
if (value[hook] !== undefined && typeof value[hook] !== `function`)
|
|
80
|
-
throw new Error(`npm.${hook} must be a function.`)
|
|
81
|
-
}
|
|
82
114
|
}
|
|
83
115
|
|
|
84
116
|
export function validatePeerRanges(peers: Record<string, string>): void {
|
package/src/npm/environment.ts
CHANGED
|
@@ -16,11 +16,11 @@ import path from "node:path"
|
|
|
16
16
|
import semver from "semver"
|
|
17
17
|
import { simpleGit } from "simple-git"
|
|
18
18
|
|
|
19
|
-
import { prepare } from "
|
|
19
|
+
import { prepare } from "../task.ts"
|
|
20
20
|
import { validatePeerRanges } from "./config.ts"
|
|
21
21
|
import type { Manifest } from "./manifest.ts"
|
|
22
22
|
import { matches, normalizeWorkspacePeers, readManifest } from "./manifest.ts"
|
|
23
|
-
import {
|
|
23
|
+
import { releaseVersion } from "./release.ts"
|
|
24
24
|
import type { NpmConfig, NpmPeerEvidence, NpmSuite } from "./types.ts"
|
|
25
25
|
|
|
26
26
|
export type Environment = {
|
|
@@ -192,14 +192,14 @@ export async function maySelectEnvironment(
|
|
|
192
192
|
)
|
|
193
193
|
if (current.name !== peer)
|
|
194
194
|
throw new Error(`Local peer identity mismatch for ${peer}.`)
|
|
195
|
-
const release = await
|
|
195
|
+
const release = await releaseVersion(config, {
|
|
196
196
|
packageName: peer,
|
|
197
197
|
packageDirectory,
|
|
198
198
|
currentVersion: current.version,
|
|
199
199
|
})
|
|
200
|
-
if (release
|
|
201
|
-
throw new Error(`Candidate ${peer} has no
|
|
202
|
-
return matches(release
|
|
200
|
+
if (release === undefined)
|
|
201
|
+
throw new Error(`Candidate ${peer} has no release version.`)
|
|
202
|
+
return matches(release, range)
|
|
203
203
|
}
|
|
204
204
|
const declared = declarations[peer]
|
|
205
205
|
return declared !== undefined && matches(declared, range)
|
|
@@ -243,18 +243,18 @@ export async function prepareEnvironment(
|
|
|
243
243
|
if (localManifest.name !== peer)
|
|
244
244
|
throw new Error(`Local peer identity mismatch for ${peer}.`)
|
|
245
245
|
validatePeerRanges(localManifest.peerDependencies ?? {})
|
|
246
|
-
const evidence = await
|
|
246
|
+
const evidence = await releaseVersion(config, {
|
|
247
247
|
packageName: peer,
|
|
248
248
|
packageDirectory: localDirectory,
|
|
249
249
|
currentVersion: localManifest.version,
|
|
250
250
|
})
|
|
251
|
-
if (evidence
|
|
252
|
-
throw new Error(`Candidate ${peer} has no
|
|
251
|
+
if (evidence === undefined)
|
|
252
|
+
throw new Error(`Candidate ${peer} has no release version.`)
|
|
253
253
|
candidates[peer] = {
|
|
254
254
|
directory: localDirectory,
|
|
255
255
|
artifact: path.resolve(base, local.artifact),
|
|
256
256
|
currentVersion: localManifest.version,
|
|
257
|
-
version: evidence
|
|
257
|
+
version: evidence,
|
|
258
258
|
manifest: localManifest,
|
|
259
259
|
}
|
|
260
260
|
}
|
package/src/npm/release.ts
CHANGED
|
@@ -3,38 +3,22 @@ import path from "node:path"
|
|
|
3
3
|
import semver from "semver"
|
|
4
4
|
import { simpleGit } from "simple-git"
|
|
5
5
|
|
|
6
|
+
import { resolveReleaseDelta, type ReleaseDeltaContext } from "../release.ts"
|
|
6
7
|
import { readBaseline } from "../baseline.ts"
|
|
7
8
|
import { withDirectoryLock } from "../directory-lock.ts"
|
|
8
9
|
import { latestReleaseTag } from "../release-tag.ts"
|
|
9
10
|
import { validatePeerRanges } from "./config.ts"
|
|
10
11
|
import type { Manifest } from "./manifest.ts"
|
|
11
|
-
import { normalizeWorkspacePeers, parseManifest
|
|
12
|
-
import type {
|
|
13
|
-
NpmConfig,
|
|
14
|
-
ReleaseContext,
|
|
15
|
-
ReleaseIntent,
|
|
16
|
-
SupportChange,
|
|
17
|
-
} from "./types.ts"
|
|
12
|
+
import { normalizeWorkspacePeers, parseManifest } from "./manifest.ts"
|
|
13
|
+
import type { NpmConfig, SupportChange } from "./types.ts"
|
|
18
14
|
|
|
19
|
-
export async function
|
|
15
|
+
export async function releaseVersion(
|
|
20
16
|
config: NpmConfig,
|
|
21
|
-
context:
|
|
22
|
-
): Promise<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
!record(result) ||
|
|
28
|
-
result.status !== `release` ||
|
|
29
|
-
typeof result.version !== `string` ||
|
|
30
|
-
!semver.valid(result.version) ||
|
|
31
|
-
semver.lt(result.version, context.currentVersion)
|
|
32
|
-
) {
|
|
33
|
-
throw new Error(
|
|
34
|
-
`Invalid release intent for ${context.packageName}; expected none or a current/newer exact version.`,
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
return { status: `release`, version: result.version }
|
|
17
|
+
context: ReleaseDeltaContext,
|
|
18
|
+
): Promise<string | undefined> {
|
|
19
|
+
return config.release
|
|
20
|
+
? (await resolveReleaseDelta(config.release, context))?.version
|
|
21
|
+
: undefined
|
|
38
22
|
}
|
|
39
23
|
|
|
40
24
|
/**
|
package/src/npm/types.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
| { status: `none` }
|
|
3
|
-
| { status: `release`; version: string }
|
|
4
|
-
|
|
5
|
-
export type ReleaseContext = {
|
|
6
|
-
packageName: string
|
|
7
|
-
packageDirectory: string
|
|
8
|
-
currentVersion: string
|
|
9
|
-
}
|
|
1
|
+
import type { VersionDelta } from "../release.ts"
|
|
10
2
|
|
|
11
3
|
export type SupportChange = {
|
|
12
4
|
peer: string
|
|
@@ -15,40 +7,8 @@ export type SupportChange = {
|
|
|
15
7
|
current: string | null
|
|
16
8
|
}
|
|
17
9
|
|
|
18
|
-
export type
|
|
19
|
-
|
|
20
|
-
delta: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type NpmSuite = {
|
|
24
|
-
/** Package-relative globs; preserve relative paths inside the stage. */
|
|
25
|
-
files: string[]
|
|
26
|
-
/** Run once; 1 means assertion failure, other nonzero codes mean setup failure. */
|
|
27
|
-
command: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type NpmConfig = {
|
|
31
|
-
packageDirectory?: string
|
|
32
|
-
artifact: string
|
|
33
|
-
representatives: string | string[]
|
|
34
|
-
lockfile: string
|
|
35
|
-
prepareCommand: string
|
|
36
|
-
installCommand?: string
|
|
37
|
-
suites: Record<string, NpmSuite>
|
|
38
|
-
/** Currently declared local peers must support and exercise each planned release. */
|
|
39
|
-
localPeers?: Record<string, { directory: string; artifact: string }>
|
|
40
|
-
baseline:
|
|
41
|
-
| { firstRelease: true }
|
|
42
|
-
| { tagPattern?: string; baselineFile?: string }
|
|
43
|
-
releaseIntent?: (
|
|
44
|
-
context: ReleaseContext,
|
|
45
|
-
) => ReleaseIntent | Promise<ReleaseIntent>
|
|
46
|
-
certifyBreaking?: (context: {
|
|
47
|
-
previousVersion: string
|
|
48
|
-
release: ReleaseEvidence
|
|
49
|
-
changes: SupportChange[]
|
|
50
|
-
}) => boolean | Promise<boolean>
|
|
51
|
-
}
|
|
10
|
+
export type NpmSuite = typeof import("./config.ts").npmSuiteSchema.infer
|
|
11
|
+
export type NpmConfig = typeof import("./config.ts").npmConfigSchema.infer
|
|
52
12
|
|
|
53
13
|
export type NpmTestSelection =
|
|
54
14
|
| { peer: string; range: string }
|
|
@@ -90,14 +50,14 @@ export type NpmReport = {
|
|
|
90
50
|
support: {
|
|
91
51
|
status:
|
|
92
52
|
| `unchanged`
|
|
93
|
-
| `
|
|
53
|
+
| `breaking-release`
|
|
94
54
|
| `end-of-support`
|
|
95
55
|
| `first-release`
|
|
96
56
|
| `not-checked`
|
|
97
57
|
| `setup-error`
|
|
98
58
|
changes: SupportChange[]
|
|
99
59
|
previousVersion?: string
|
|
100
|
-
release?:
|
|
60
|
+
release?: VersionDelta
|
|
101
61
|
error?: string
|
|
102
62
|
}
|
|
103
63
|
errors: string[]
|
package/src/release.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// cspell:words premajor preminor prepatch
|
|
2
|
+
import semver from "semver"
|
|
3
|
+
import { type } from "arktype"
|
|
4
|
+
|
|
5
|
+
export type ReleaseDeltaContext = {
|
|
6
|
+
packageName: string
|
|
7
|
+
packageDirectory: string
|
|
8
|
+
currentVersion: string
|
|
9
|
+
/** The selected historical release, when checking compatibility. */
|
|
10
|
+
baseline?: { version: string; ref?: string; commit?: string }
|
|
11
|
+
}
|
|
12
|
+
export type VersionTransition = {
|
|
13
|
+
previousVersion: string
|
|
14
|
+
version: string
|
|
15
|
+
delta:
|
|
16
|
+
| `major`
|
|
17
|
+
| `premajor`
|
|
18
|
+
| `minor`
|
|
19
|
+
| `preminor`
|
|
20
|
+
| `patch`
|
|
21
|
+
| `prepatch`
|
|
22
|
+
| `prerelease`
|
|
23
|
+
| `release`
|
|
24
|
+
| `none`
|
|
25
|
+
}
|
|
26
|
+
export type ReleaseDeltaResolver = (
|
|
27
|
+
context: ReleaseDeltaContext,
|
|
28
|
+
) => VersionTransition | undefined | Promise<VersionTransition | undefined>
|
|
29
|
+
export type VersionDelta = VersionTransition & { breaking: boolean }
|
|
30
|
+
export type SemverPolicyOptions = { beforeV1?: `major` | `minor` | `patch` }
|
|
31
|
+
export type ReleasePolicyFunction = (delta: VersionTransition) => boolean
|
|
32
|
+
export type ReleasePolicy = ReleasePolicyFunction
|
|
33
|
+
export const releaseSchema = type({
|
|
34
|
+
"+": `reject`,
|
|
35
|
+
"resolveDelta?": type(`Function`).as<ReleaseDeltaResolver>(),
|
|
36
|
+
"policy?": type(`Function`).as<ReleasePolicy>(),
|
|
37
|
+
})
|
|
38
|
+
export type ReleaseConfig = typeof releaseSchema.infer
|
|
39
|
+
|
|
40
|
+
/** Choose which version increments permit breaking changes. Prerelease-only increments do not. */
|
|
41
|
+
export function semverPolicy({
|
|
42
|
+
beforeV1 = `major`,
|
|
43
|
+
}: SemverPolicyOptions = {}): ReleasePolicyFunction {
|
|
44
|
+
if (![`major`, `minor`, `patch`].includes(beforeV1))
|
|
45
|
+
throw new Error(`Invalid beforeV1 policy.`)
|
|
46
|
+
return ({ previousVersion, version }) => {
|
|
47
|
+
const previous = new semver.SemVer(previousVersion)
|
|
48
|
+
const next = new semver.SemVer(version)
|
|
49
|
+
if (next.major > previous.major) return true
|
|
50
|
+
if (previous.major !== 0 || beforeV1 === `major`) return false
|
|
51
|
+
return (
|
|
52
|
+
next.minor > previous.minor ||
|
|
53
|
+
(beforeV1 === `patch` && next.patch > previous.patch)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Describe a version transition without deciding whether it permits breakage. */
|
|
59
|
+
export function versionTransition(
|
|
60
|
+
previousVersion: string,
|
|
61
|
+
version: string,
|
|
62
|
+
): VersionTransition {
|
|
63
|
+
if (!semver.valid(previousVersion) || !semver.valid(version))
|
|
64
|
+
throw new Error(`Expected exact semantic versions.`)
|
|
65
|
+
return {
|
|
66
|
+
previousVersion,
|
|
67
|
+
version,
|
|
68
|
+
delta: semver.diff(previousVersion, version) ?? `none`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pure comparison; equal and older versions never permit breaking changes. */
|
|
73
|
+
export function versionDelta(
|
|
74
|
+
previousVersion: string,
|
|
75
|
+
version: string,
|
|
76
|
+
policy: ReleasePolicy = semverPolicy(),
|
|
77
|
+
): VersionDelta {
|
|
78
|
+
const evidence = versionTransition(previousVersion, version)
|
|
79
|
+
const breaking = semver.gt(version, previousVersion)
|
|
80
|
+
? policy(evidence)
|
|
81
|
+
: false
|
|
82
|
+
if (typeof breaking !== `boolean`)
|
|
83
|
+
throw new Error(`Release policy must return a boolean.`)
|
|
84
|
+
return { ...evidence, breaking }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateRelease(
|
|
88
|
+
value: unknown,
|
|
89
|
+
): asserts value is ReleaseConfig {
|
|
90
|
+
if (typeof value !== `object` || value === null || Array.isArray(value))
|
|
91
|
+
throw new Error(`release must be an object.`)
|
|
92
|
+
if (`version` in value)
|
|
93
|
+
throw new Error(
|
|
94
|
+
`release.version was removed. Use release.resolveDelta to return a version transition.`,
|
|
95
|
+
)
|
|
96
|
+
releaseSchema.assert(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Resolve and validate release evidence; policy is applied separately. */
|
|
100
|
+
export async function resolveReleaseDelta(
|
|
101
|
+
config: ReleaseConfig,
|
|
102
|
+
context: ReleaseDeltaContext,
|
|
103
|
+
): Promise<VersionTransition | undefined> {
|
|
104
|
+
validateRelease(config)
|
|
105
|
+
const transition = config.resolveDelta
|
|
106
|
+
? await config.resolveDelta(context)
|
|
107
|
+
: versionTransition(
|
|
108
|
+
context.baseline?.version ?? context.currentVersion,
|
|
109
|
+
context.currentVersion,
|
|
110
|
+
)
|
|
111
|
+
if (transition === undefined) return undefined
|
|
112
|
+
if (
|
|
113
|
+
typeof transition !== `object` ||
|
|
114
|
+
transition === null ||
|
|
115
|
+
Array.isArray(transition) ||
|
|
116
|
+
typeof transition.previousVersion !== `string` ||
|
|
117
|
+
!semver.valid(transition.previousVersion) ||
|
|
118
|
+
typeof transition.version !== `string` ||
|
|
119
|
+
!semver.valid(transition.version) ||
|
|
120
|
+
!semver.valid(context.currentVersion) ||
|
|
121
|
+
semver.lt(transition.version, context.currentVersion)
|
|
122
|
+
)
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid release transition for ${context.packageName}; expected exact versions and a current/newer target.`,
|
|
125
|
+
)
|
|
126
|
+
if (
|
|
127
|
+
context.baseline &&
|
|
128
|
+
transition.previousVersion !== context.baseline.version
|
|
129
|
+
)
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Release transition for ${context.packageName} does not match the selected baseline.`,
|
|
132
|
+
)
|
|
133
|
+
const evidence = versionTransition(
|
|
134
|
+
transition.previousVersion,
|
|
135
|
+
transition.version,
|
|
136
|
+
)
|
|
137
|
+
if (transition.delta !== evidence.delta)
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Release transition for ${context.packageName} has an inconsistent semver delta.`,
|
|
140
|
+
)
|
|
141
|
+
// Only evidence crosses this boundary; resolver-supplied policy decisions are ignored.
|
|
142
|
+
return evidence
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Removed hooks must fail explicitly, including in untyped configurations. */
|
|
146
|
+
export function rejectCertificationOptions(value: object): void {
|
|
147
|
+
for (const key of [`certifyCommand`, `releaseIntent`, `certifyBreaking`]) {
|
|
148
|
+
if (key in value)
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${key} was removed. Use release.resolveDelta and release.policy instead.`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -8,6 +8,8 @@ export async function command(
|
|
|
8
8
|
commandLine: string,
|
|
9
9
|
cwd: string,
|
|
10
10
|
): Promise<CommandResult> {
|
|
11
|
+
if (typeof commandLine !== `string` || !commandLine.trim())
|
|
12
|
+
throw new Error(`Commands must be nonempty shell strings.`)
|
|
11
13
|
return await new Promise((resolve, reject) => {
|
|
12
14
|
const child = spawn(commandLine, {
|
|
13
15
|
cwd,
|