@dbx-tools/projen 0.6.229 → 0.7.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 +59 -85
- package/index.ts +9 -1
- package/package.json +8 -5
- package/src/openapi.ts +9 -1
- package/src/project-js.ts +68 -10
- package/src/project-py.ts +133 -4
- package/src/project-rs.ts +392 -25
- package/src/release-catalog.ts +832 -0
- package/src/release-dispatch.ts +3 -2
- package/src/release-plan.ts +236 -0
- package/src/release-please.ts +242 -0
- package/src/release.ts +428 -0
- package/tasks/publish-npm.ts +4 -4
- package/tasks/publish-python.ts +56 -14
- package/tasks/publish.ts +60 -30
- package/tasks/release-bootstrap.ts +59 -0
- package/tasks/release-plan.ts +89 -0
- package/tasks/release-please.ts +163 -0
- package/tasks/release-summary-units.ts +44 -0
- package/tasks/release-summary.ts +33 -7
- package/tasks/uniffi-python.d.ts +5 -0
- package/tasks/uniffi-python.js +21 -2
- package/tasks/uniffi-release.mjs +8 -3
- package/tasks/version-check.ts +43 -15
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-language release-unit ownership and dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is the version lookup and release-planning seam shared by every
|
|
5
|
+
* generated project. Fixed mode preserves the root VERSION contract while the
|
|
6
|
+
* same registrations and graph are exercised before independent versions are
|
|
7
|
+
* enabled.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
16
|
+
import { Component, JsonFile, type Project } from "projen";
|
|
17
|
+
import { toPosix } from "./packages.ts";
|
|
18
|
+
import { readWorkspaceVersion } from "./workspace-version.ts";
|
|
19
|
+
|
|
20
|
+
/** Version source used by the generated workspace. */
|
|
21
|
+
export type DBXToolsVersioningMode = "fixed" | "independent";
|
|
22
|
+
|
|
23
|
+
/** Runtime family represented by a release project. */
|
|
24
|
+
export type ReleaseProjectLanguage = "javascript" | "python" | "rust";
|
|
25
|
+
|
|
26
|
+
/** Publishable output owned by one release unit. */
|
|
27
|
+
export type ReleaseArtifactKind = "npm" | "pypi" | "cargo" | "github-binary" | "documentation";
|
|
28
|
+
|
|
29
|
+
/** Relationship between two projects or release units. */
|
|
30
|
+
export type ReleaseEdgeKind =
|
|
31
|
+
| "runtime"
|
|
32
|
+
| "peer"
|
|
33
|
+
| "optional"
|
|
34
|
+
| "bundled"
|
|
35
|
+
| "build"
|
|
36
|
+
| "test"
|
|
37
|
+
| "development"
|
|
38
|
+
| "generated"
|
|
39
|
+
| "publish";
|
|
40
|
+
|
|
41
|
+
/** When a dependency release requires a dependent release. */
|
|
42
|
+
export type ReleasePropagation = "never" | "outside-range" | "always";
|
|
43
|
+
|
|
44
|
+
/** Explicit grouping rule applied after every project has registered. */
|
|
45
|
+
export interface ReleaseUnitRule {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly component?: string;
|
|
48
|
+
readonly projectPaths?: readonly string[];
|
|
49
|
+
readonly projectIdentities?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Root catalog configuration. */
|
|
53
|
+
export interface DBXToolsReleaseCatalogOptions {
|
|
54
|
+
readonly mode?: DBXToolsVersioningMode;
|
|
55
|
+
readonly manifestFile?: string;
|
|
56
|
+
readonly graphFile?: string;
|
|
57
|
+
readonly releasePleaseConfigFile?: string;
|
|
58
|
+
readonly bootstrapSha?: string;
|
|
59
|
+
readonly units?: readonly ReleaseUnitRule[];
|
|
60
|
+
readonly externalProjects?: readonly ExternalReleaseProjectRegistration[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Dependency supplied by an ecosystem adapter. */
|
|
64
|
+
export interface ReleaseDependencyInput {
|
|
65
|
+
readonly target: string;
|
|
66
|
+
readonly kind: ReleaseEdgeKind;
|
|
67
|
+
readonly requirement?: string;
|
|
68
|
+
readonly propagation?: ReleasePropagation;
|
|
69
|
+
readonly publishOrder?: boolean;
|
|
70
|
+
readonly internal?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Project registration supplied by an ecosystem adapter. */
|
|
74
|
+
export interface ReleaseProjectRegistration {
|
|
75
|
+
readonly language: ReleaseProjectLanguage;
|
|
76
|
+
readonly identity: string | (() => string);
|
|
77
|
+
readonly publish?: boolean | (() => boolean);
|
|
78
|
+
readonly unit?: string;
|
|
79
|
+
readonly component?: string;
|
|
80
|
+
readonly sourcePaths?: readonly string[];
|
|
81
|
+
readonly dependencies?:
|
|
82
|
+
readonly ReleaseDependencyInput[] | (() => readonly ReleaseDependencyInput[]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Project-like workspace member synthesized outside the attached Projen tree. */
|
|
86
|
+
export interface ExternalReleaseProjectRegistration {
|
|
87
|
+
readonly path: string;
|
|
88
|
+
readonly language: ReleaseProjectLanguage;
|
|
89
|
+
readonly identity: string;
|
|
90
|
+
readonly publish?: boolean;
|
|
91
|
+
readonly unit?: string;
|
|
92
|
+
readonly component?: string;
|
|
93
|
+
readonly sourcePaths?: readonly string[];
|
|
94
|
+
readonly dependencies?: readonly ReleaseDependencyInput[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Virtual publication target owned by a release unit. */
|
|
98
|
+
export interface ReleaseArtifactRegistration {
|
|
99
|
+
readonly id: string;
|
|
100
|
+
readonly kind: ReleaseArtifactKind;
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly path?: string;
|
|
103
|
+
readonly generated?: boolean;
|
|
104
|
+
readonly publish?: boolean;
|
|
105
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Serialized project node. */
|
|
109
|
+
export interface ReleaseProjectNode {
|
|
110
|
+
readonly id: string;
|
|
111
|
+
readonly identity: string;
|
|
112
|
+
readonly language: ReleaseProjectLanguage;
|
|
113
|
+
readonly path: string;
|
|
114
|
+
readonly unit?: string;
|
|
115
|
+
readonly publish: boolean;
|
|
116
|
+
readonly sourceHash: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Serialized publication target. */
|
|
120
|
+
export interface ReleaseArtifact {
|
|
121
|
+
readonly id: string;
|
|
122
|
+
readonly kind: ReleaseArtifactKind;
|
|
123
|
+
readonly name: string;
|
|
124
|
+
readonly unit: string;
|
|
125
|
+
readonly path?: string;
|
|
126
|
+
readonly generated: boolean;
|
|
127
|
+
readonly publish: boolean;
|
|
128
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Serialized dependency edge. */
|
|
132
|
+
export interface ReleaseDependencyEdge {
|
|
133
|
+
readonly from: string;
|
|
134
|
+
readonly to: string;
|
|
135
|
+
readonly kind: ReleaseEdgeKind;
|
|
136
|
+
readonly requirement?: string;
|
|
137
|
+
readonly propagation: ReleasePropagation;
|
|
138
|
+
readonly publishOrder: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Serialized release unit. */
|
|
142
|
+
export interface ReleaseUnit {
|
|
143
|
+
readonly id: string;
|
|
144
|
+
readonly component: string;
|
|
145
|
+
readonly version: string;
|
|
146
|
+
readonly projects: readonly string[];
|
|
147
|
+
readonly artifacts: readonly string[];
|
|
148
|
+
readonly sourceHash: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Generated execution graph consumed by validation and release tasks. */
|
|
152
|
+
export interface ReleaseUnitGraph {
|
|
153
|
+
readonly schemaVersion: 1;
|
|
154
|
+
readonly mode: DBXToolsVersioningMode;
|
|
155
|
+
readonly units: readonly ReleaseUnit[];
|
|
156
|
+
readonly projects: readonly ReleaseProjectNode[];
|
|
157
|
+
readonly artifacts: readonly ReleaseArtifact[];
|
|
158
|
+
readonly edges: readonly ReleaseDependencyEdge[];
|
|
159
|
+
readonly publishBatches: readonly (readonly string[])[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface RegisteredProject {
|
|
163
|
+
readonly project: Project;
|
|
164
|
+
readonly registration: ReleaseProjectRegistration;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface RegisteredArtifact extends ReleaseArtifactRegistration {
|
|
168
|
+
readonly unit: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
172
|
+
".git",
|
|
173
|
+
".projen",
|
|
174
|
+
".pytest_cache",
|
|
175
|
+
".ruff_cache",
|
|
176
|
+
".venv",
|
|
177
|
+
"__pycache__",
|
|
178
|
+
"dist",
|
|
179
|
+
"lib",
|
|
180
|
+
"node_modules",
|
|
181
|
+
"target",
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const IGNORED_FILES = new Set([
|
|
185
|
+
"Cargo.lock",
|
|
186
|
+
"Cargo.toml",
|
|
187
|
+
"package.json",
|
|
188
|
+
"pyproject.toml",
|
|
189
|
+
"uv.lock",
|
|
190
|
+
"bun.lock",
|
|
191
|
+
"pnpm-lock.yaml",
|
|
192
|
+
"yarn.lock",
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/** Stable default component id for one publishable project. */
|
|
196
|
+
export function defaultReleaseUnitId(language: ReleaseProjectLanguage, identity: string): string {
|
|
197
|
+
const prefix = language === "javascript" ? "node" : language === "python" ? "python" : "rs";
|
|
198
|
+
const unscoped = identity.includes("/")
|
|
199
|
+
? identity.slice(identity.lastIndexOf("/") + 1)
|
|
200
|
+
: identity;
|
|
201
|
+
const name = unscoped
|
|
202
|
+
.replace(/^dbx-tools-/, "")
|
|
203
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
204
|
+
.replace(/^-+|-+$/g, "")
|
|
205
|
+
.toLowerCase();
|
|
206
|
+
if (!name) throw new Error(`Cannot derive a release unit from ${identity}`);
|
|
207
|
+
return `${prefix}-${name}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Default propagation policy for an ecosystem dependency. */
|
|
211
|
+
export function defaultReleasePropagation(kind: ReleaseEdgeKind): ReleasePropagation {
|
|
212
|
+
if (kind === "bundled" || kind === "generated") return "always";
|
|
213
|
+
if (kind === "runtime" || kind === "peer" || kind === "optional") return "outside-range";
|
|
214
|
+
return "never";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Whether an edge participates in publication ordering by default. */
|
|
218
|
+
export function defaultPublishOrder(kind: ReleaseEdgeKind): boolean {
|
|
219
|
+
return !["test", "development"].includes(kind);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Read one component version, falling back to fixed workspace compatibility. */
|
|
223
|
+
export function readReleaseUnitVersion(
|
|
224
|
+
root: string,
|
|
225
|
+
component: string,
|
|
226
|
+
manifestFile = ".release-please-manifest.json",
|
|
227
|
+
): string {
|
|
228
|
+
const path = resolve(root, manifestFile);
|
|
229
|
+
if (!existsSync(path)) return readWorkspaceVersion(root);
|
|
230
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
231
|
+
const version = parsed[`.release-units/${component}`];
|
|
232
|
+
if (typeof version !== "string") {
|
|
233
|
+
throw new Error(`Release Please manifest is missing .release-units/${component}`);
|
|
234
|
+
}
|
|
235
|
+
return version;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Root release catalog shared by every generated language project.
|
|
240
|
+
*
|
|
241
|
+
* Registrations remain mutable until synthesis. The serialized graph is lazy,
|
|
242
|
+
* so repository mixins and workspace components may add ownership and edges
|
|
243
|
+
* after project construction.
|
|
244
|
+
*/
|
|
245
|
+
export class DBXToolsReleaseCatalog extends Component {
|
|
246
|
+
readonly mode: DBXToolsVersioningMode;
|
|
247
|
+
readonly manifestFile: string;
|
|
248
|
+
readonly graphFile: string;
|
|
249
|
+
readonly releasePleaseConfigFile: string;
|
|
250
|
+
readonly bootstrapSha?: string;
|
|
251
|
+
|
|
252
|
+
private readonly projects = new Map<Project, RegisteredProject>();
|
|
253
|
+
private readonly externalProjects = new Map<string, ExternalReleaseProjectRegistration>();
|
|
254
|
+
private readonly artifacts = new Map<string, RegisteredArtifact>();
|
|
255
|
+
private readonly explicitDependencies: Array<{
|
|
256
|
+
from: string;
|
|
257
|
+
dependency: ReleaseDependencyInput;
|
|
258
|
+
}> = [];
|
|
259
|
+
private readonly rules: ReleaseUnitRule[];
|
|
260
|
+
private readonly markerFiles = new Set<string>();
|
|
261
|
+
|
|
262
|
+
constructor(project: Project, options: DBXToolsReleaseCatalogOptions = {}) {
|
|
263
|
+
super(project);
|
|
264
|
+
this.mode = options.mode ?? "fixed";
|
|
265
|
+
this.manifestFile = options.manifestFile ?? ".release-please-manifest.json";
|
|
266
|
+
this.graphFile = options.graphFile ?? ".projen/release-units.json";
|
|
267
|
+
this.releasePleaseConfigFile = options.releasePleaseConfigFile ?? "release-please-config.json";
|
|
268
|
+
this.bootstrapSha = options.bootstrapSha;
|
|
269
|
+
this.rules = [...(options.units ?? [])];
|
|
270
|
+
for (const external of options.externalProjects ?? []) this.registerExternalProject(external);
|
|
271
|
+
new JsonFile(project, this.graphFile, {
|
|
272
|
+
marker: false,
|
|
273
|
+
readonly: true,
|
|
274
|
+
obj: () => this.graph(),
|
|
275
|
+
});
|
|
276
|
+
if (this.mode === "independent") {
|
|
277
|
+
new JsonFile(project, this.releasePleaseConfigFile, {
|
|
278
|
+
marker: false,
|
|
279
|
+
readonly: true,
|
|
280
|
+
obj: () => this.releasePleaseConfig(),
|
|
281
|
+
});
|
|
282
|
+
project
|
|
283
|
+
.addTask("release:bootstrap", {
|
|
284
|
+
description: "Bootstrap Release Please state from release units",
|
|
285
|
+
})
|
|
286
|
+
.exec("bun node_modules/@dbx-tools/projen/tasks/release-bootstrap.ts");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Register one attached Projen project. */
|
|
291
|
+
registerProject(project: Project, registration: ReleaseProjectRegistration): void {
|
|
292
|
+
if (this.projects.has(project)) {
|
|
293
|
+
throw new Error(`Release project already registered: ${project.outdir}`);
|
|
294
|
+
}
|
|
295
|
+
this.projects.set(project, { project, registration });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Register a workspace member synthesized outside the attached project tree. */
|
|
299
|
+
registerExternalProject(registration: ExternalReleaseProjectRegistration): void {
|
|
300
|
+
const path = toPosix(registration.path).replace(/^\.\//, "");
|
|
301
|
+
if (!path || path === ".") throw new Error("External release project requires a child path");
|
|
302
|
+
if (this.externalProjects.has(path)) {
|
|
303
|
+
throw new Error(`External release project already registered: ${path}`);
|
|
304
|
+
}
|
|
305
|
+
this.externalProjects.set(path, { ...registration, path });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Replace grouping metadata for an already registered project. */
|
|
309
|
+
configureProject(
|
|
310
|
+
project: Project,
|
|
311
|
+
options: Pick<ReleaseProjectRegistration, "unit" | "component" | "publish" | "sourcePaths">,
|
|
312
|
+
): void {
|
|
313
|
+
const current = this.projects.get(project);
|
|
314
|
+
if (!current) throw new Error(`Release project is not registered: ${project.outdir}`);
|
|
315
|
+
this.projects.set(project, {
|
|
316
|
+
project,
|
|
317
|
+
registration: { ...current.registration, ...options },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Add an explicit grouping rule before synthesis. */
|
|
322
|
+
addUnit(rule: ReleaseUnitRule): void {
|
|
323
|
+
if (this.rules.some((candidate) => candidate.id === rule.id)) {
|
|
324
|
+
throw new Error(`Duplicate release unit: ${rule.id}`);
|
|
325
|
+
}
|
|
326
|
+
this.rules.push(rule);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Register a generated or otherwise virtual artifact. */
|
|
330
|
+
registerArtifact(unit: string, artifact: ReleaseArtifactRegistration): void {
|
|
331
|
+
if (this.artifacts.has(artifact.id)) {
|
|
332
|
+
throw new Error(`Duplicate release artifact: ${artifact.id}`);
|
|
333
|
+
}
|
|
334
|
+
this.artifacts.set(artifact.id, { ...artifact, unit });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Add an explicit project or unit dependency. */
|
|
338
|
+
addDependency(from: string, dependency: ReleaseDependencyInput): void {
|
|
339
|
+
this.explicitDependencies.push({ from, dependency: { ...dependency, internal: true } });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Current version for a registered project. */
|
|
343
|
+
versionFor(project: Project): string {
|
|
344
|
+
const registered = this.projects.get(project);
|
|
345
|
+
if (!registered) throw new Error(`Release project is not registered: ${project.outdir}`);
|
|
346
|
+
if (!resolveValue(registered.registration.publish ?? true)) {
|
|
347
|
+
return this.mode === "independent" ? "0.0.0" : readWorkspaceVersion(this.project.outdir);
|
|
348
|
+
}
|
|
349
|
+
return this.versionForUnit(this.unitFor(registered));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Stable unit, component, and version identity for a registered project. */
|
|
353
|
+
releaseIdentityFor(project: Project): {
|
|
354
|
+
id: string;
|
|
355
|
+
component: string;
|
|
356
|
+
version: string;
|
|
357
|
+
} {
|
|
358
|
+
const registered = this.projects.get(project);
|
|
359
|
+
if (!registered) throw new Error(`Release project is not registered: ${project.outdir}`);
|
|
360
|
+
const id = this.unitFor(registered);
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
component: this.componentFor(registered, id),
|
|
364
|
+
version: this.versionFor(project),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Current version for a project selected by its repository-relative path. */
|
|
369
|
+
versionForPath(path: string): string {
|
|
370
|
+
const normalized = toPosix(path).replace(/^\.\//, "") || ".";
|
|
371
|
+
const attached = [...this.projects.values()].find(
|
|
372
|
+
(registered) => this.projectId(registered.project) === normalized,
|
|
373
|
+
);
|
|
374
|
+
if (attached) return this.versionFor(attached.project);
|
|
375
|
+
const external = this.externalProjects.get(normalized);
|
|
376
|
+
if (!external) {
|
|
377
|
+
if (this.mode === "fixed") return readWorkspaceVersion(this.project.outdir);
|
|
378
|
+
throw new Error(`Release project path is not registered: ${normalized}`);
|
|
379
|
+
}
|
|
380
|
+
if (external.publish === false) {
|
|
381
|
+
return this.mode === "independent" ? "0.0.0" : readWorkspaceVersion(this.project.outdir);
|
|
382
|
+
}
|
|
383
|
+
const unit =
|
|
384
|
+
external.unit ??
|
|
385
|
+
this.rules.find(
|
|
386
|
+
(rule) =>
|
|
387
|
+
rule.projectPaths?.includes(normalized) ||
|
|
388
|
+
rule.projectIdentities?.includes(external.identity),
|
|
389
|
+
)?.id ??
|
|
390
|
+
defaultReleaseUnitId(external.language, external.identity);
|
|
391
|
+
return this.versionForUnit(unit);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Resolve a version before the corresponding Projen project is constructed. */
|
|
395
|
+
versionForRegistration(
|
|
396
|
+
language: ReleaseProjectLanguage,
|
|
397
|
+
identity: string,
|
|
398
|
+
path: string,
|
|
399
|
+
unit?: string,
|
|
400
|
+
): string {
|
|
401
|
+
const normalized = toPosix(path).replace(/^\.\//, "") || ".";
|
|
402
|
+
const resolvedUnit =
|
|
403
|
+
unit ??
|
|
404
|
+
this.rules.find(
|
|
405
|
+
(rule) =>
|
|
406
|
+
rule.projectPaths?.includes(normalized) || rule.projectIdentities?.includes(identity),
|
|
407
|
+
)?.id ??
|
|
408
|
+
defaultReleaseUnitId(language, identity);
|
|
409
|
+
return this.versionForUnit(resolvedUnit);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Current version for a release unit. */
|
|
413
|
+
versionForUnit(unit: string): string {
|
|
414
|
+
if (this.mode === "fixed") return readWorkspaceVersion(this.project.outdir);
|
|
415
|
+
const component = this.rules.find((rule) => rule.id === unit)?.component ?? unit;
|
|
416
|
+
const versions = this.readIndependentVersions();
|
|
417
|
+
const key = `.release-units/${component}`;
|
|
418
|
+
const version = versions[key];
|
|
419
|
+
if (!version) throw new Error(`Release Please manifest is missing ${key}`);
|
|
420
|
+
return version;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Build and validate the normalized release graph. */
|
|
424
|
+
graph(): ReleaseUnitGraph {
|
|
425
|
+
const attachedProjects = [...this.projects.values()].map((registered) =>
|
|
426
|
+
this.projectNode(registered),
|
|
427
|
+
);
|
|
428
|
+
const externalProjects = [...this.externalProjects.values()].map((registered) =>
|
|
429
|
+
this.externalProjectNode(registered),
|
|
430
|
+
);
|
|
431
|
+
const projects = [...attachedProjects, ...externalProjects].sort((a, b) =>
|
|
432
|
+
a.id.localeCompare(b.id),
|
|
433
|
+
);
|
|
434
|
+
const projectsByIdentity = new Map<string, ReleaseProjectNode[]>();
|
|
435
|
+
for (const project of projects) {
|
|
436
|
+
projectsByIdentity.set(project.identity, [
|
|
437
|
+
...(projectsByIdentity.get(project.identity) ?? []),
|
|
438
|
+
project,
|
|
439
|
+
]);
|
|
440
|
+
}
|
|
441
|
+
const projectById = new Map(projects.map((project) => [project.id, project]));
|
|
442
|
+
if (projectById.size !== projects.length) {
|
|
443
|
+
throw new Error("Release project paths must be unique");
|
|
444
|
+
}
|
|
445
|
+
const componentByProjectId = new Map<string, string>();
|
|
446
|
+
for (const registered of this.projects.values()) {
|
|
447
|
+
const id = this.projectId(registered.project);
|
|
448
|
+
const node = projectById.get(id);
|
|
449
|
+
if (node?.unit) componentByProjectId.set(id, this.componentFor(registered, node.unit));
|
|
450
|
+
}
|
|
451
|
+
for (const registered of this.externalProjects.values()) {
|
|
452
|
+
const node = projectById.get(registered.path);
|
|
453
|
+
if (node?.unit) componentByProjectId.set(node.id, registered.component ?? node.unit);
|
|
454
|
+
}
|
|
455
|
+
const units = new Map<string, { component: string; projects: string[]; artifacts: string[] }>();
|
|
456
|
+
|
|
457
|
+
for (const project of projects) {
|
|
458
|
+
if (!project.unit) continue;
|
|
459
|
+
const component = componentByProjectId.get(project.id) ?? project.unit;
|
|
460
|
+
const current = units.get(project.unit) ?? { component, projects: [], artifacts: [] };
|
|
461
|
+
if (current.component !== component) {
|
|
462
|
+
throw new Error(`Release unit ${project.unit} has conflicting components`);
|
|
463
|
+
}
|
|
464
|
+
current.projects.push(project.id);
|
|
465
|
+
units.set(project.unit, current);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const artifacts = [...this.artifacts.values()]
|
|
469
|
+
.map<ReleaseArtifact>((artifact) => {
|
|
470
|
+
if (!units.has(artifact.unit)) {
|
|
471
|
+
throw new Error(`Release artifact ${artifact.id} has unknown owner ${artifact.unit}`);
|
|
472
|
+
}
|
|
473
|
+
units.get(artifact.unit)!.artifacts.push(artifact.id);
|
|
474
|
+
return {
|
|
475
|
+
id: artifact.id,
|
|
476
|
+
kind: artifact.kind,
|
|
477
|
+
name: artifact.name,
|
|
478
|
+
unit: artifact.unit,
|
|
479
|
+
...(artifact.path ? { path: artifact.path } : {}),
|
|
480
|
+
generated: artifact.generated ?? false,
|
|
481
|
+
publish: artifact.publish !== false,
|
|
482
|
+
...(artifact.data ? { data: artifact.data } : {}),
|
|
483
|
+
};
|
|
484
|
+
})
|
|
485
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
486
|
+
|
|
487
|
+
const components = new Map<string, string>();
|
|
488
|
+
for (const [id, unit] of units) {
|
|
489
|
+
const owner = components.get(unit.component);
|
|
490
|
+
if (owner && owner !== id) {
|
|
491
|
+
throw new Error(`Release component ${unit.component} is owned by ${owner} and ${id}`);
|
|
492
|
+
}
|
|
493
|
+
components.set(unit.component, id);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const edges = this.dependencies(projectsByIdentity, projectById);
|
|
497
|
+
const unitEdges = edges.filter((edge) => edge.publishOrder && edge.from !== edge.to);
|
|
498
|
+
const batches = publicationBatches([...units.keys()], unitEdges);
|
|
499
|
+
const serializedUnits = [...units.entries()]
|
|
500
|
+
.map<ReleaseUnit>(([id, unit]) => {
|
|
501
|
+
const hashes = unit.projects
|
|
502
|
+
.map((projectId) => projectById.get(projectId)?.sourceHash)
|
|
503
|
+
.filter((hash): hash is string => Boolean(hash));
|
|
504
|
+
return {
|
|
505
|
+
id,
|
|
506
|
+
component: unit.component,
|
|
507
|
+
version: this.versionForResolvedUnit(id, unit.component),
|
|
508
|
+
projects: [...unit.projects].sort(),
|
|
509
|
+
artifacts: [...unit.artifacts].sort(),
|
|
510
|
+
sourceHash: hashStrings(hashes),
|
|
511
|
+
};
|
|
512
|
+
})
|
|
513
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
schemaVersion: 1,
|
|
517
|
+
mode: this.mode,
|
|
518
|
+
units: serializedUnits,
|
|
519
|
+
projects,
|
|
520
|
+
artifacts,
|
|
521
|
+
edges,
|
|
522
|
+
publishBatches: batches,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Generated Release Please manifest-mode configuration. */
|
|
527
|
+
releasePleaseConfig(): Record<string, unknown> {
|
|
528
|
+
return {
|
|
529
|
+
$schema:
|
|
530
|
+
"https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
531
|
+
"release-type": "simple",
|
|
532
|
+
"separate-pull-requests": false,
|
|
533
|
+
"include-component-in-tag": true,
|
|
534
|
+
"include-v-in-tag": true,
|
|
535
|
+
...(this.bootstrapSha ? { "bootstrap-sha": this.bootstrapSha } : {}),
|
|
536
|
+
packages: Object.fromEntries(
|
|
537
|
+
this.graph().units.map((unit) => [
|
|
538
|
+
`.release-units/${unit.component}`,
|
|
539
|
+
{
|
|
540
|
+
"release-type": "simple",
|
|
541
|
+
"package-name": unit.id,
|
|
542
|
+
component: unit.component,
|
|
543
|
+
"changelog-path": "CHANGELOG.md",
|
|
544
|
+
"version-file": "version.txt",
|
|
545
|
+
},
|
|
546
|
+
]),
|
|
547
|
+
),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
public override preSynthesize(): void {
|
|
552
|
+
const graph = this.graph();
|
|
553
|
+
if (this.mode === "fixed") return;
|
|
554
|
+
for (const unit of graph.units) {
|
|
555
|
+
const path = `.release-units/${unit.component}/source.json`;
|
|
556
|
+
if (this.markerFiles.has(path)) continue;
|
|
557
|
+
this.markerFiles.add(path);
|
|
558
|
+
new JsonFile(this.project, path, {
|
|
559
|
+
marker: false,
|
|
560
|
+
readonly: true,
|
|
561
|
+
obj: () => {
|
|
562
|
+
const current = this.graph().units.find((candidate) => candidate.id === unit.id);
|
|
563
|
+
if (!current) throw new Error(`Release unit disappeared during synthesis: ${unit.id}`);
|
|
564
|
+
return {
|
|
565
|
+
schemaVersion: 1,
|
|
566
|
+
unit: current.id,
|
|
567
|
+
component: current.component,
|
|
568
|
+
sourceHash: current.sourceHash,
|
|
569
|
+
};
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private projectNode(registered: RegisteredProject): ReleaseProjectNode {
|
|
576
|
+
const identity = resolveValue(registered.registration.identity);
|
|
577
|
+
const publish = resolveValue(registered.registration.publish ?? true);
|
|
578
|
+
const path = toPosix(relative(this.project.outdir, registered.project.outdir)) || ".";
|
|
579
|
+
const unit = publish ? this.unitFor(registered, identity, path) : undefined;
|
|
580
|
+
const sourcePaths = registered.registration.sourcePaths?.length
|
|
581
|
+
? registered.registration.sourcePaths.map((sourcePath) =>
|
|
582
|
+
resolve(this.project.outdir, sourcePath),
|
|
583
|
+
)
|
|
584
|
+
: [registered.project.outdir];
|
|
585
|
+
return {
|
|
586
|
+
id: this.projectId(registered.project),
|
|
587
|
+
identity,
|
|
588
|
+
language: registered.registration.language,
|
|
589
|
+
path,
|
|
590
|
+
...(unit ? { unit } : {}),
|
|
591
|
+
publish,
|
|
592
|
+
sourceHash: hashPaths(this.project.outdir, sourcePaths),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private externalProjectNode(
|
|
597
|
+
registration: ExternalReleaseProjectRegistration,
|
|
598
|
+
): ReleaseProjectNode {
|
|
599
|
+
const publish = registration.publish !== false;
|
|
600
|
+
const unit = publish
|
|
601
|
+
? (registration.unit ??
|
|
602
|
+
this.rules.find(
|
|
603
|
+
(candidate) =>
|
|
604
|
+
candidate.projectPaths?.includes(registration.path) ||
|
|
605
|
+
candidate.projectIdentities?.includes(registration.identity),
|
|
606
|
+
)?.id ??
|
|
607
|
+
defaultReleaseUnitId(registration.language, registration.identity))
|
|
608
|
+
: undefined;
|
|
609
|
+
const sourcePaths = (registration.sourcePaths ?? [registration.path]).map((sourcePath) =>
|
|
610
|
+
resolve(this.project.outdir, sourcePath),
|
|
611
|
+
);
|
|
612
|
+
return {
|
|
613
|
+
id: registration.path,
|
|
614
|
+
identity: registration.identity,
|
|
615
|
+
language: registration.language,
|
|
616
|
+
path: registration.path,
|
|
617
|
+
...(unit ? { unit } : {}),
|
|
618
|
+
publish,
|
|
619
|
+
sourceHash: hashPaths(this.project.outdir, sourcePaths),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private unitFor(
|
|
624
|
+
registered: RegisteredProject,
|
|
625
|
+
identity = resolveValue(registered.registration.identity),
|
|
626
|
+
path = toPosix(relative(this.project.outdir, registered.project.outdir)) || ".",
|
|
627
|
+
): string {
|
|
628
|
+
if (registered.registration.unit) return registered.registration.unit;
|
|
629
|
+
const rule = this.rules.find(
|
|
630
|
+
(candidate) =>
|
|
631
|
+
candidate.projectPaths?.includes(path) || candidate.projectIdentities?.includes(identity),
|
|
632
|
+
);
|
|
633
|
+
return rule?.id ?? defaultReleaseUnitId(registered.registration.language, identity);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private componentFor(registered: RegisteredProject, unit: string): string {
|
|
637
|
+
return (
|
|
638
|
+
registered.registration.component ??
|
|
639
|
+
this.rules.find((rule) => rule.id === unit)?.component ??
|
|
640
|
+
unit
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private projectId(project: Project): string {
|
|
645
|
+
return toPosix(relative(this.project.outdir, project.outdir)) || ".";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private dependencies(
|
|
649
|
+
byIdentity: ReadonlyMap<string, readonly ReleaseProjectNode[]>,
|
|
650
|
+
byId: ReadonlyMap<string, ReleaseProjectNode>,
|
|
651
|
+
): ReleaseDependencyEdge[] {
|
|
652
|
+
const edges = new Map<string, ReleaseDependencyEdge>();
|
|
653
|
+
const add = (from: ReleaseProjectNode, dependency: ReleaseDependencyInput): void => {
|
|
654
|
+
const identityMatches = byIdentity.get(dependency.target) ?? [];
|
|
655
|
+
const sameLanguage = identityMatches.filter(
|
|
656
|
+
(candidate) => candidate.language === from.language,
|
|
657
|
+
);
|
|
658
|
+
const target =
|
|
659
|
+
byId.get(dependency.target) ??
|
|
660
|
+
(sameLanguage.length === 1
|
|
661
|
+
? sameLanguage[0]
|
|
662
|
+
: identityMatches.length === 1
|
|
663
|
+
? identityMatches[0]
|
|
664
|
+
: undefined);
|
|
665
|
+
if (!target) {
|
|
666
|
+
if (dependency.internal) {
|
|
667
|
+
const reason = identityMatches.length > 1 ? "ambiguous" : "unknown";
|
|
668
|
+
throw new Error(
|
|
669
|
+
`${from.identity} references ${reason} internal project ${dependency.target}`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!from.unit || !target.unit) return;
|
|
675
|
+
const edge: ReleaseDependencyEdge = {
|
|
676
|
+
from: from.unit,
|
|
677
|
+
to: target.unit,
|
|
678
|
+
kind: dependency.kind,
|
|
679
|
+
...(dependency.requirement ? { requirement: dependency.requirement } : {}),
|
|
680
|
+
propagation: dependency.propagation ?? defaultReleasePropagation(dependency.kind),
|
|
681
|
+
publishOrder: dependency.publishOrder ?? defaultPublishOrder(dependency.kind),
|
|
682
|
+
};
|
|
683
|
+
const key = [
|
|
684
|
+
edge.from,
|
|
685
|
+
edge.to,
|
|
686
|
+
edge.kind,
|
|
687
|
+
edge.requirement ?? "",
|
|
688
|
+
edge.propagation,
|
|
689
|
+
edge.publishOrder,
|
|
690
|
+
].join("\0");
|
|
691
|
+
edges.set(key, edge);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
for (const registered of this.projects.values()) {
|
|
695
|
+
const from = byId.get(this.projectId(registered.project));
|
|
696
|
+
if (!from) continue;
|
|
697
|
+
for (const dependency of resolveValue(registered.registration.dependencies ?? [])) {
|
|
698
|
+
add(from, dependency);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const registered of this.externalProjects.values()) {
|
|
702
|
+
const from = byId.get(registered.path);
|
|
703
|
+
if (!from) continue;
|
|
704
|
+
for (const dependency of registered.dependencies ?? []) add(from, dependency);
|
|
705
|
+
}
|
|
706
|
+
for (const explicit of this.explicitDependencies) {
|
|
707
|
+
const matches = byIdentity.get(explicit.from) ?? [];
|
|
708
|
+
const from = byId.get(explicit.from) ?? (matches.length === 1 ? matches[0] : undefined);
|
|
709
|
+
if (!from) throw new Error(`Unknown release dependency source ${explicit.from}`);
|
|
710
|
+
add(from, explicit.dependency);
|
|
711
|
+
}
|
|
712
|
+
return [...edges.values()].sort((a, b) =>
|
|
713
|
+
[a.from, a.to, a.kind].join("\0").localeCompare([b.from, b.to, b.kind].join("\0")),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private versionForResolvedUnit(unit: string, component: string): string {
|
|
718
|
+
if (this.mode === "fixed") return readWorkspaceVersion(this.project.outdir);
|
|
719
|
+
const key = `.release-units/${component}`;
|
|
720
|
+
const version = this.readIndependentVersions()[key];
|
|
721
|
+
if (!version) throw new Error(`Release Please manifest is missing ${key} for ${unit}`);
|
|
722
|
+
return version;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private readIndependentVersions(): Record<string, string> {
|
|
726
|
+
const path = resolve(this.project.outdir, this.manifestFile);
|
|
727
|
+
if (!existsSync(path)) throw new Error(`Independent version manifest not found: ${path}`);
|
|
728
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
729
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
730
|
+
throw new Error(`Independent version manifest must contain an object: ${path}`);
|
|
731
|
+
}
|
|
732
|
+
return Object.fromEntries(
|
|
733
|
+
Object.entries(parsed).map(([key, value]) => {
|
|
734
|
+
if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)) {
|
|
735
|
+
throw new Error(`Invalid independent version for ${key}: ${String(value)}`);
|
|
736
|
+
}
|
|
737
|
+
return [key, value];
|
|
738
|
+
}),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Publish dependency batches ordered with dependencies before dependents. */
|
|
744
|
+
export function publicationBatches(
|
|
745
|
+
units: readonly string[],
|
|
746
|
+
edges: readonly Pick<ReleaseDependencyEdge, "from" | "to" | "publishOrder">[],
|
|
747
|
+
): string[][] {
|
|
748
|
+
const remaining = new Map(units.map((unit) => [unit, new Set<string>()]));
|
|
749
|
+
for (const edge of edges) {
|
|
750
|
+
if (!edge.publishOrder || edge.from === edge.to) continue;
|
|
751
|
+
if (!remaining.has(edge.from) || !remaining.has(edge.to)) {
|
|
752
|
+
throw new Error(`Release edge references an unknown unit: ${edge.from} -> ${edge.to}`);
|
|
753
|
+
}
|
|
754
|
+
remaining.get(edge.from)!.add(edge.to);
|
|
755
|
+
}
|
|
756
|
+
const batches: string[][] = [];
|
|
757
|
+
while (remaining.size > 0) {
|
|
758
|
+
const batch = [...remaining.entries()]
|
|
759
|
+
.filter(([, dependencies]) =>
|
|
760
|
+
[...dependencies].every((dependency) => !remaining.has(dependency)),
|
|
761
|
+
)
|
|
762
|
+
.map(([unit]) => unit)
|
|
763
|
+
.sort();
|
|
764
|
+
if (batch.length === 0) {
|
|
765
|
+
const cycle = [...remaining.keys()].sort().join(", ");
|
|
766
|
+
throw new Error(`Cyclic release publication dependencies: ${cycle}`);
|
|
767
|
+
}
|
|
768
|
+
batches.push(batch);
|
|
769
|
+
for (const unit of batch) remaining.delete(unit);
|
|
770
|
+
}
|
|
771
|
+
return batches;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function resolveValue<T>(value: T | (() => T)): T {
|
|
775
|
+
return typeof value === "function" ? (value as () => T)() : value;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function hashPaths(root: string, paths: readonly string[]): string {
|
|
779
|
+
const files = gitReleaseFiles(root, paths) ?? paths.flatMap((path) => releaseFiles(path)).sort();
|
|
780
|
+
const hash = createHash("sha256");
|
|
781
|
+
for (const file of files) {
|
|
782
|
+
hash.update(toPosix(relative(root, file)));
|
|
783
|
+
hash.update("\0");
|
|
784
|
+
hash.update(readFileSync(file));
|
|
785
|
+
hash.update("\0");
|
|
786
|
+
}
|
|
787
|
+
return hash.digest("hex");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function gitReleaseFiles(root: string, paths: readonly string[]): string[] | undefined {
|
|
791
|
+
const relativePaths = paths.map((path) => toPosix(relative(root, path)));
|
|
792
|
+
try {
|
|
793
|
+
const output = execFileSync(
|
|
794
|
+
"git",
|
|
795
|
+
["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...relativePaths],
|
|
796
|
+
{ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
797
|
+
);
|
|
798
|
+
return output
|
|
799
|
+
.split("\0")
|
|
800
|
+
.filter(Boolean)
|
|
801
|
+
.map((path) => resolve(root, path))
|
|
802
|
+
.filter((path) => existsSync(path))
|
|
803
|
+
.sort();
|
|
804
|
+
} catch {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function releaseFiles(path: string): string[] {
|
|
810
|
+
if (!existsSync(path)) return [];
|
|
811
|
+
const entries = readdirSync(path, { withFileTypes: true });
|
|
812
|
+
return entries.flatMap((entry) => {
|
|
813
|
+
if (entry.isDirectory()) {
|
|
814
|
+
return IGNORED_DIRECTORIES.has(entry.name) ? [] : releaseFiles(join(path, entry.name));
|
|
815
|
+
}
|
|
816
|
+
if (!entry.isFile()) return [];
|
|
817
|
+
if (IGNORED_FILES.has(entry.name) || entry.name.endsWith(".lock")) return [];
|
|
818
|
+
if (entry.name.startsWith("tsconfig") || basename(path) === ".release-units") {
|
|
819
|
+
return [];
|
|
820
|
+
}
|
|
821
|
+
return [join(path, entry.name)];
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function hashStrings(values: readonly string[]): string {
|
|
826
|
+
const hash = createHash("sha256");
|
|
827
|
+
for (const value of [...values].sort()) {
|
|
828
|
+
hash.update(value);
|
|
829
|
+
hash.update("\0");
|
|
830
|
+
}
|
|
831
|
+
return hash.digest("hex");
|
|
832
|
+
}
|