breakage 0.8.4 → 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 +164 -46
- package/dist/breakage-DIY49fzP.js +1349 -0
- package/dist/breakage-DIY49fzP.js.map +1 -0
- package/dist/breakage.d.ts +179 -11
- package/dist/breakage.d.ts.map +1 -1
- package/dist/breakage.js +3 -1
- package/dist/breakage.x.js +169 -67
- 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 +236 -0
- package/package.json +51 -11
- package/src/breakage.ts +136 -70
- package/src/breakage.x.ts +160 -112
- package/src/changesets.ts +51 -0
- package/src/config.ts +128 -0
- package/src/npm/check.ts +449 -0
- package/src/npm/cli.ts +130 -0
- package/src/npm/config.ts +118 -0
- package/src/npm/environment.ts +661 -0
- package/src/npm/manifest.ts +134 -0
- package/src/npm/release.ts +173 -0
- package/src/npm/types.ts +64 -0
- package/src/release.ts +153 -0
- package/src/task.ts +54 -0
- package/dist/breakage-Dc4IldeW.js +0 -435
- package/dist/breakage-Dc4IldeW.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.prelude.$configPath.schema.json +0 -31
- package/dist/breakage.prelude.schema.json +0 -31
- package/dist/breakage.schema.schema.json +0 -10
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
import semver from "semver"
|
|
4
|
+
|
|
5
|
+
export type Manifest = {
|
|
6
|
+
name: string
|
|
7
|
+
version: string
|
|
8
|
+
type?: string
|
|
9
|
+
dependencies?: Record<string, string>
|
|
10
|
+
optionalDependencies?: Record<string, string>
|
|
11
|
+
devDependencies?: Record<string, string>
|
|
12
|
+
peerDependencies?: Record<string, string>
|
|
13
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
14
|
+
breakage?: { candidates?: string[] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function record(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === `object` && value !== null && !Array.isArray(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function packageName(value: string): boolean {
|
|
22
|
+
return /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseManifest(value: unknown, label: string): Manifest {
|
|
26
|
+
if (
|
|
27
|
+
!record(value) ||
|
|
28
|
+
typeof value.name !== `string` ||
|
|
29
|
+
!packageName(value.name) ||
|
|
30
|
+
typeof value.version !== `string` ||
|
|
31
|
+
!semver.valid(value.version)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error(`Invalid package identity in ${label}.`)
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
value.peerDependenciesMeta !== undefined &&
|
|
37
|
+
(!record(value.peerDependenciesMeta) ||
|
|
38
|
+
Object.values(value.peerDependenciesMeta).some(
|
|
39
|
+
(entry) =>
|
|
40
|
+
!record(entry) ||
|
|
41
|
+
(entry.optional !== undefined && typeof entry.optional !== `boolean`),
|
|
42
|
+
))
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`Invalid peerDependenciesMeta in ${label}.`)
|
|
45
|
+
}
|
|
46
|
+
for (const field of [
|
|
47
|
+
`dependencies`,
|
|
48
|
+
`optionalDependencies`,
|
|
49
|
+
`devDependencies`,
|
|
50
|
+
`peerDependencies`,
|
|
51
|
+
] as const) {
|
|
52
|
+
const entries = value[field]
|
|
53
|
+
if (
|
|
54
|
+
entries !== undefined &&
|
|
55
|
+
(!record(entries) ||
|
|
56
|
+
Object.entries(entries).some(
|
|
57
|
+
([name, spec]) => !packageName(name) || typeof spec !== `string`,
|
|
58
|
+
))
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(`Invalid ${field} in ${label}.`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
value.breakage !== undefined &&
|
|
65
|
+
(!record(value.breakage) ||
|
|
66
|
+
(value.breakage.candidates !== undefined &&
|
|
67
|
+
(!Array.isArray(value.breakage.candidates) ||
|
|
68
|
+
value.breakage.candidates.some(
|
|
69
|
+
(name: unknown) => typeof name !== `string` || !packageName(name),
|
|
70
|
+
))))
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`Invalid candidate peers in ${label}.`)
|
|
73
|
+
}
|
|
74
|
+
return value as Manifest
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readManifest(filename: string): Promise<Manifest> {
|
|
78
|
+
return parseManifest(JSON.parse(await readFile(filename, `utf8`)), filename)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Current support is authored explicitly; only historical shorthand is resolved. */
|
|
82
|
+
export async function normalizeWorkspacePeers(
|
|
83
|
+
manifest: Manifest,
|
|
84
|
+
versionAtBaseline?: (peer: string) => Promise<string>,
|
|
85
|
+
): Promise<Manifest> {
|
|
86
|
+
if (!manifest.peerDependencies) return manifest
|
|
87
|
+
const entries = await Promise.all(
|
|
88
|
+
Object.entries(manifest.peerDependencies).map(async ([peer, range]) => {
|
|
89
|
+
if (!range.startsWith(`workspace:`)) return [peer, range] as const
|
|
90
|
+
const requested = range.slice(`workspace:`.length)
|
|
91
|
+
if ([``, `*`, `^`, `~`].includes(requested)) {
|
|
92
|
+
if (!versionAtBaseline)
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Moving workspace peer range ${peer}: ${range} in ${manifest.name}. Declare explicit support eras, for example ^1.0.0 || ^2.0.0, so local version changes cannot change the support promise.`,
|
|
95
|
+
)
|
|
96
|
+
const version = await versionAtBaseline(peer)
|
|
97
|
+
if (!semver.valid(version))
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Invalid historical workspace version for ${peer}: ${version}.`,
|
|
100
|
+
)
|
|
101
|
+
return [
|
|
102
|
+
peer,
|
|
103
|
+
`${requested === `^` || requested === `~` ? requested : ``}${version}`,
|
|
104
|
+
] as const
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
eras(requested)
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unsupported workspace peer range for ${peer}: ${range}.`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return [peer, requested] as const
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
return { ...manifest, peerDependencies: Object.fromEntries(entries) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function eras(range: string): string[] {
|
|
120
|
+
const parts = range.split(`||`).map((part) => part.trim())
|
|
121
|
+
if (parts.some((part) => !part || semver.validRange(part) === null)) {
|
|
122
|
+
throw new Error(`Invalid peer range: ${range}.`)
|
|
123
|
+
}
|
|
124
|
+
return parts
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Previews only represent an explicitly enrolled exact preview. */
|
|
128
|
+
export function matches(version: string, range: string): boolean {
|
|
129
|
+
return eras(range).some((era) =>
|
|
130
|
+
semver.prerelease(version)
|
|
131
|
+
? semver.valid(era.replace(/^=\s*/, ``)) === semver.valid(version)
|
|
132
|
+
: semver.satisfies(version, era),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import semver from "semver"
|
|
4
|
+
import { simpleGit } from "simple-git"
|
|
5
|
+
|
|
6
|
+
import { resolveReleaseDelta, type ReleaseDeltaContext } from "../release.ts"
|
|
7
|
+
import { readBaseline } from "../baseline.ts"
|
|
8
|
+
import { withDirectoryLock } from "../directory-lock.ts"
|
|
9
|
+
import { latestReleaseTag } from "../release-tag.ts"
|
|
10
|
+
import { validatePeerRanges } from "./config.ts"
|
|
11
|
+
import type { Manifest } from "./manifest.ts"
|
|
12
|
+
import { normalizeWorkspacePeers, parseManifest } from "./manifest.ts"
|
|
13
|
+
import type { NpmConfig, SupportChange } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
export async function releaseVersion(
|
|
16
|
+
config: NpmConfig,
|
|
17
|
+
context: ReleaseDeltaContext,
|
|
18
|
+
): Promise<string | undefined> {
|
|
19
|
+
return config.release
|
|
20
|
+
? (await resolveReleaseDelta(config.release, context))?.version
|
|
21
|
+
: undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compare the union, including adjacent alternatives. semver.subset compares each
|
|
26
|
+
* conjunction to a single conjunction and cannot prove coverage by their union.
|
|
27
|
+
* Membership can change only at comparator boundaries or prerelease tuple gates.
|
|
28
|
+
*/
|
|
29
|
+
export function includesRange(current: string, previous: string): boolean {
|
|
30
|
+
const ranges = [new semver.Range(previous), new semver.Range(current)]
|
|
31
|
+
const samples = new Set<string>([`0.0.0`, `0.0.0-0`])
|
|
32
|
+
for (const range of ranges) {
|
|
33
|
+
for (const conjunction of range.set) {
|
|
34
|
+
for (const comparator of conjunction) {
|
|
35
|
+
const version = comparator.semver
|
|
36
|
+
if (typeof version === `symbol`) continue
|
|
37
|
+
const base = `${version.major}.${version.minor}.${version.patch}`
|
|
38
|
+
samples.add(version.version)
|
|
39
|
+
samples.add(base)
|
|
40
|
+
samples.add(`${base}-0`)
|
|
41
|
+
// The immediate successor in a prerelease sequence appends numeric zero.
|
|
42
|
+
if (version.prerelease.length) samples.add(`${version.version}.0`)
|
|
43
|
+
// Stable versions are discrete; the next patch starts another tuple gate.
|
|
44
|
+
const next = semver.inc(base, `patch`)
|
|
45
|
+
if (next) {
|
|
46
|
+
samples.add(next)
|
|
47
|
+
samples.add(`${next}-0`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...samples].every(
|
|
53
|
+
(version) =>
|
|
54
|
+
!semver.satisfies(version, previous) ||
|
|
55
|
+
semver.satisfies(version, current),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function supportChanges(
|
|
60
|
+
previous: Manifest,
|
|
61
|
+
current: Manifest,
|
|
62
|
+
): SupportChange[] {
|
|
63
|
+
const before = previous.peerDependencies ?? {}
|
|
64
|
+
const after = current.peerDependencies ?? {}
|
|
65
|
+
validatePeerRanges(before)
|
|
66
|
+
validatePeerRanges(after)
|
|
67
|
+
return Object.entries(after).flatMap(([peer, range]) => {
|
|
68
|
+
const old = before[peer]
|
|
69
|
+
const wasOptional = previous.peerDependenciesMeta?.[peer]?.optional === true
|
|
70
|
+
const isOptional = current.peerDependenciesMeta?.[peer]?.optional === true
|
|
71
|
+
// Absence scenarios are not generated, but making an optional peer required
|
|
72
|
+
// still changes the installation contract for existing consumers.
|
|
73
|
+
return (old === undefined && isOptional) ||
|
|
74
|
+
(old !== undefined &&
|
|
75
|
+
includesRange(range, old) &&
|
|
76
|
+
!(wasOptional && !isOptional))
|
|
77
|
+
? []
|
|
78
|
+
: [
|
|
79
|
+
{
|
|
80
|
+
peer,
|
|
81
|
+
reason:
|
|
82
|
+
old === undefined || (wasOptional && !isOptional)
|
|
83
|
+
? (`required-peer` as const)
|
|
84
|
+
: (`narrowed-range` as const),
|
|
85
|
+
previous: old ?? null,
|
|
86
|
+
current: range,
|
|
87
|
+
},
|
|
88
|
+
]
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function previousManifest(
|
|
93
|
+
config: NpmConfig,
|
|
94
|
+
directory: string,
|
|
95
|
+
): Promise<Manifest | undefined> {
|
|
96
|
+
if (`firstRelease` in config.baseline) return undefined
|
|
97
|
+
const git = simpleGit(directory)
|
|
98
|
+
let commit: string
|
|
99
|
+
if (config.baseline.baselineFile) {
|
|
100
|
+
commit = (
|
|
101
|
+
await readBaseline(
|
|
102
|
+
git,
|
|
103
|
+
path.resolve(directory, config.baseline.baselineFile),
|
|
104
|
+
config.baseline,
|
|
105
|
+
)
|
|
106
|
+
).commit
|
|
107
|
+
} else {
|
|
108
|
+
const tags = await git.listRemote([`--tags`, `origin`])
|
|
109
|
+
const ref = latestReleaseTag(tags, config.baseline.tagPattern)
|
|
110
|
+
if (!ref)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`No npm release baseline found; configure a tag pattern or explicitly declare firstRelease.`,
|
|
113
|
+
)
|
|
114
|
+
const refs = new Map(
|
|
115
|
+
tags
|
|
116
|
+
.trim()
|
|
117
|
+
.split(`\n`)
|
|
118
|
+
.map((line) => {
|
|
119
|
+
const [sha, name] = line.split(`\t`)
|
|
120
|
+
return [name, sha]
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
const resolved = refs.get(`${ref}^{}`) ?? refs.get(ref)
|
|
124
|
+
if (!resolved || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(resolved))
|
|
125
|
+
throw new Error(`Invalid release commit for ${ref}.`)
|
|
126
|
+
commit = resolved
|
|
127
|
+
const common = await git.revparse([
|
|
128
|
+
`--path-format=absolute`,
|
|
129
|
+
`--git-common-dir`,
|
|
130
|
+
])
|
|
131
|
+
await withDirectoryLock(path.join(common, `breakage-fetch.lock`), () =>
|
|
132
|
+
git.fetch([`origin`, commit, `--no-tags`, `--no-write-fetch-head`]),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
const prefix = await git.revparse([`--show-prefix`])
|
|
136
|
+
const manifest = parseManifest(
|
|
137
|
+
JSON.parse(await git.show([`${commit}:${prefix}package.json`])),
|
|
138
|
+
`release ${commit}`,
|
|
139
|
+
)
|
|
140
|
+
return await normalizeWorkspacePeers(manifest, async (peer) => {
|
|
141
|
+
const local = config.localPeers?.[peer]
|
|
142
|
+
if (!local)
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Historical workspace peer ${peer} needs a localPeers directory to resolve release ${commit}.`,
|
|
145
|
+
)
|
|
146
|
+
const root = await git.revparse([`--show-toplevel`])
|
|
147
|
+
const relative = path.relative(
|
|
148
|
+
root,
|
|
149
|
+
path.resolve(directory, local.directory),
|
|
150
|
+
)
|
|
151
|
+
if (
|
|
152
|
+
relative === `..` ||
|
|
153
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
154
|
+
path.isAbsolute(relative)
|
|
155
|
+
)
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Historical workspace peer ${peer} escapes the release repository.`,
|
|
158
|
+
)
|
|
159
|
+
const filename = path
|
|
160
|
+
.join(relative, `package.json`)
|
|
161
|
+
.split(path.sep)
|
|
162
|
+
.join(`/`)
|
|
163
|
+
const historical = parseManifest(
|
|
164
|
+
JSON.parse(await git.show([`${commit}:${filename}`])),
|
|
165
|
+
`release ${commit}:${filename}`,
|
|
166
|
+
)
|
|
167
|
+
if (historical.name !== peer)
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Historical workspace peer identity mismatch for ${peer}.`,
|
|
170
|
+
)
|
|
171
|
+
return historical.version
|
|
172
|
+
})
|
|
173
|
+
}
|
package/src/npm/types.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { VersionDelta } from "../release.ts"
|
|
2
|
+
|
|
3
|
+
export type SupportChange = {
|
|
4
|
+
peer: string
|
|
5
|
+
reason: `narrowed-range` | `required-peer`
|
|
6
|
+
previous: string | null
|
|
7
|
+
current: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type NpmSuite = typeof import("./config.ts").npmSuiteSchema.infer
|
|
11
|
+
export type NpmConfig = typeof import("./config.ts").npmConfigSchema.infer
|
|
12
|
+
|
|
13
|
+
export type NpmTestSelection =
|
|
14
|
+
| { peer: string; range: string }
|
|
15
|
+
| { representative: string }
|
|
16
|
+
|
|
17
|
+
export type NpmPeerEvidence = {
|
|
18
|
+
version: string
|
|
19
|
+
path: string
|
|
20
|
+
source: `installed` | `candidate`
|
|
21
|
+
archiveVersion?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NpmSuiteResult = {
|
|
25
|
+
peer: string
|
|
26
|
+
status: `passed` | `compatibility-failure` | `setup-error` | `not-run`
|
|
27
|
+
output?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type NpmRepresentativeResult = {
|
|
31
|
+
path: string
|
|
32
|
+
status:
|
|
33
|
+
| `passed`
|
|
34
|
+
| `audited`
|
|
35
|
+
| `skipped`
|
|
36
|
+
| `compatibility-failure`
|
|
37
|
+
| `setup-error`
|
|
38
|
+
peers: Record<string, NpmPeerEvidence>
|
|
39
|
+
suites: NpmSuiteResult[]
|
|
40
|
+
warnings: string[]
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type NpmReport = {
|
|
45
|
+
package: { name: string; version: string }
|
|
46
|
+
mode: `check` | `coverage-only` | `test`
|
|
47
|
+
exitCode: 0 | 1 | 2
|
|
48
|
+
representatives: NpmRepresentativeResult[]
|
|
49
|
+
coverage: { peer: string; era: string; representatives: string[] }[]
|
|
50
|
+
support: {
|
|
51
|
+
status:
|
|
52
|
+
| `unchanged`
|
|
53
|
+
| `breaking-release`
|
|
54
|
+
| `end-of-support`
|
|
55
|
+
| `first-release`
|
|
56
|
+
| `not-checked`
|
|
57
|
+
| `setup-error`
|
|
58
|
+
changes: SupportChange[]
|
|
59
|
+
previousVersion?: string
|
|
60
|
+
release?: VersionDelta
|
|
61
|
+
error?: string
|
|
62
|
+
}
|
|
63
|
+
errors: string[]
|
|
64
|
+
}
|
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
|
+
}
|
package/src/task.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export type CommandResult = { code: number; output: string }
|
|
5
|
+
|
|
6
|
+
/** Capture output per environment so parallel runs cannot interleave their reports. */
|
|
7
|
+
export async function command(
|
|
8
|
+
commandLine: string,
|
|
9
|
+
cwd: string,
|
|
10
|
+
): Promise<CommandResult> {
|
|
11
|
+
if (typeof commandLine !== `string` || !commandLine.trim())
|
|
12
|
+
throw new Error(`Commands must be nonempty shell strings.`)
|
|
13
|
+
return await new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(commandLine, {
|
|
15
|
+
cwd,
|
|
16
|
+
shell: true,
|
|
17
|
+
env: {
|
|
18
|
+
...process.env,
|
|
19
|
+
NODE_PATH: ``,
|
|
20
|
+
PATH: [
|
|
21
|
+
path.join(cwd, `node_modules/.bin`),
|
|
22
|
+
path.join(cwd, `../node_modules/.bin`),
|
|
23
|
+
process.env.PATH,
|
|
24
|
+
].join(path.delimiter),
|
|
25
|
+
},
|
|
26
|
+
stdio: [`ignore`, `pipe`, `pipe`],
|
|
27
|
+
})
|
|
28
|
+
let output = ``
|
|
29
|
+
const collect = (chunk: Buffer): void => {
|
|
30
|
+
// Bound diagnostics without interrupting a command that emits lots of output.
|
|
31
|
+
output = (output + chunk.toString()).slice(-1_000_000)
|
|
32
|
+
}
|
|
33
|
+
child.stdout.on(`data`, collect)
|
|
34
|
+
child.stderr.on(`data`, collect)
|
|
35
|
+
child.on(`error`, reject)
|
|
36
|
+
child.on(`close`, (code, signal) =>
|
|
37
|
+
resolve({ code: signal ? 2 : (code ?? 2), output }),
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function prepare(commandLine: string, cwd: string): Promise<void> {
|
|
43
|
+
const result = await command(commandLine, cwd)
|
|
44
|
+
if (result.code !== 0)
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Command failed (${result.code}) in ${cwd}: ${commandLine}\n${result.output}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function errorMessage(error: unknown): string {
|
|
51
|
+
return error instanceof Error
|
|
52
|
+
? `${error.message}${error.cause ? `\n${errorMessage(error.cause)}` : ``}`
|
|
53
|
+
: String(error)
|
|
54
|
+
}
|