@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.
@@ -9,8 +9,9 @@ export const RELEASE_SUMMARY_PROVIDER_NAMES = ["cursor", "codex", "claude"] as c
9
9
  export type ReleaseSummaryProviderName = (typeof RELEASE_SUMMARY_PROVIDER_NAMES)[number];
10
10
 
11
11
  /** Immutable repository path for one version's release summary. */
12
- export function releaseSummaryFile(version: string): string {
13
- return `${RELEASE_SUMMARY_DIRECTORY}/v${version.replace(/^v/, "")}.md`;
12
+ export function releaseSummaryFile(version: string, component?: string): string {
13
+ const prefix = component ? `${component.replace(/[^a-zA-Z0-9-]+/g, "-")}-` : "";
14
+ return `${RELEASE_SUMMARY_DIRECTORY}/${prefix}v${version.replace(/^v/, "")}.md`;
14
15
  }
15
16
 
16
17
  export const RELEASE_SUMMARY_FILE = releaseSummaryFile(RELEASE_VERSION);
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Deterministic affected release plan derived from manifest and graph changes.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type {
8
+ ReleaseArtifact,
9
+ ReleaseProjectNode,
10
+ ReleaseUnit,
11
+ ReleaseUnitGraph,
12
+ } from "./release-catalog.ts";
13
+
14
+ /** Why a unit entered one release. */
15
+ export type ReleaseReason = "direct" | "propagated";
16
+
17
+ /** One independently versioned unit selected for publication. */
18
+ export interface PlannedReleaseUnit {
19
+ readonly id: string;
20
+ readonly component: string;
21
+ readonly oldVersion: string;
22
+ readonly newVersion: string;
23
+ readonly tag: string;
24
+ readonly reason: ReleaseReason;
25
+ readonly sourceHash: string;
26
+ }
27
+
28
+ /** One selected package publication. */
29
+ export interface PlannedPackage {
30
+ readonly unit: string;
31
+ readonly identity: string;
32
+ readonly path: string;
33
+ readonly version: string;
34
+ }
35
+
36
+ /** One selected virtual artifact publication. */
37
+ export interface PlannedArtifact extends ReleaseArtifact {
38
+ readonly version: string;
39
+ }
40
+
41
+ /** One selected native Rust matrix row and the packages built in that row. */
42
+ export interface PlannedRustTarget {
43
+ readonly os: string;
44
+ readonly cpu: string;
45
+ readonly node: string;
46
+ readonly cargo: string;
47
+ readonly runner: string;
48
+ readonly python: string;
49
+ readonly libc: string;
50
+ readonly cargoExcludes: string;
51
+ readonly packages: readonly string[];
52
+ }
53
+
54
+ /** Complete plan persisted as a workflow artifact and used for recovery. */
55
+ export interface ReleasePlan {
56
+ readonly schemaVersion: 1;
57
+ readonly units: readonly PlannedReleaseUnit[];
58
+ readonly publishBatches: readonly (readonly string[])[];
59
+ readonly nodePackages: readonly PlannedPackage[];
60
+ readonly pythonPackages: readonly PlannedPackage[];
61
+ readonly rustPackages: readonly PlannedPackage[];
62
+ readonly artifacts: readonly PlannedArtifact[];
63
+ readonly rustTargets: readonly PlannedRustTarget[];
64
+ readonly stages: {
65
+ readonly rust: boolean;
66
+ readonly python: boolean;
67
+ readonly node: boolean;
68
+ readonly github: boolean;
69
+ readonly docs: boolean;
70
+ };
71
+ readonly omittedStages: readonly string[];
72
+ }
73
+
74
+ /** Build the affected plan from the previous and reviewed release graphs. */
75
+ export function buildReleasePlan(
76
+ current: ReleaseUnitGraph,
77
+ previous?: ReleaseUnitGraph,
78
+ ): ReleasePlan {
79
+ const previousUnits = new Map(previous?.units.map((unit) => [unit.id, unit]) ?? []);
80
+ const units = current.units.flatMap<PlannedReleaseUnit>((unit) => {
81
+ const old = previousUnits.get(unit.id);
82
+ if (!old || old.version === unit.version) return [];
83
+ return [
84
+ {
85
+ id: unit.id,
86
+ component: unit.component,
87
+ oldVersion: old.version,
88
+ newVersion: unit.version,
89
+ tag: `${unit.component}-v${unit.version}`,
90
+ reason: old.sourceHash === unit.sourceHash ? "propagated" : "direct",
91
+ sourceHash: unit.sourceHash,
92
+ },
93
+ ];
94
+ });
95
+ const selected = new Set(units.map((unit) => unit.id));
96
+ const versions = new Map(units.map((unit) => [unit.id, unit.newVersion]));
97
+ const packagePlan = (language: ReleaseProjectNode["language"]): PlannedPackage[] =>
98
+ current.projects
99
+ .filter(
100
+ (project) =>
101
+ project.publish &&
102
+ project.language === language &&
103
+ project.unit !== undefined &&
104
+ selected.has(project.unit),
105
+ )
106
+ .map((project) => ({
107
+ unit: project.unit!,
108
+ identity: project.identity,
109
+ path: project.path,
110
+ version: versions.get(project.unit!)!,
111
+ }))
112
+ .sort((a, b) => a.identity.localeCompare(b.identity));
113
+ const artifacts = current.artifacts
114
+ .filter((artifact) => artifact.publish && selected.has(artifact.unit))
115
+ .map((artifact) => ({
116
+ ...artifact,
117
+ version: versions.get(artifact.unit)!,
118
+ }));
119
+ const rustPackagesByUnit = new Map<string, string[]>();
120
+ for (const project of current.projects) {
121
+ if (
122
+ project.language === "rust" &&
123
+ project.publish &&
124
+ project.unit &&
125
+ selected.has(project.unit)
126
+ ) {
127
+ rustPackagesByUnit.set(project.unit, [
128
+ ...(rustPackagesByUnit.get(project.unit) ?? []),
129
+ project.identity,
130
+ ]);
131
+ }
132
+ }
133
+ const rustTargetsByKey = new Map<string, PlannedRustTarget>();
134
+ for (const artifact of artifacts) {
135
+ const targets = artifact.data?.targets;
136
+ if (!Array.isArray(targets)) continue;
137
+ for (const value of targets) {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
139
+ const target = value as Record<string, unknown>;
140
+ const required = ["os", "cpu", "node", "cargo", "runner", "python"] as const;
141
+ if (!required.every((field) => typeof target[field] === "string")) continue;
142
+ const key = String(target.node);
143
+ const currentTarget = rustTargetsByKey.get(key);
144
+ rustTargetsByKey.set(key, {
145
+ os: String(target.os),
146
+ cpu: String(target.cpu),
147
+ node: String(target.node),
148
+ cargo: String(target.cargo),
149
+ runner: String(target.runner),
150
+ python: String(target.python),
151
+ libc: typeof target.libc === "string" ? target.libc : "",
152
+ cargoExcludes: typeof target.cargoExcludes === "string" ? target.cargoExcludes : "",
153
+ packages: [
154
+ ...new Set([
155
+ ...(currentTarget?.packages ?? []),
156
+ ...(rustPackagesByUnit.get(artifact.unit) ?? []),
157
+ ]),
158
+ ].sort(),
159
+ });
160
+ }
161
+ }
162
+ const rustTargets = [...rustTargetsByKey.values()].sort((a, b) => a.node.localeCompare(b.node));
163
+ const publishBatches = current.publishBatches
164
+ .map((batch) => batch.filter((unit) => selected.has(unit)))
165
+ .filter((batch) => batch.length > 0);
166
+ const nodePackages = packagePlan("javascript");
167
+ const pythonPackages = packagePlan("python");
168
+ const rustPackages = packagePlan("rust");
169
+ const stages = {
170
+ rust:
171
+ rustPackages.length > 0 ||
172
+ artifacts.some((artifact) => ["cargo", "github-binary"].includes(artifact.kind)),
173
+ python: pythonPackages.length > 0 || artifacts.some((artifact) => artifact.kind === "pypi"),
174
+ node: nodePackages.length > 0 || artifacts.some((artifact) => artifact.kind === "npm"),
175
+ github: artifacts.some((artifact) => artifact.kind === "github-binary"),
176
+ docs: units.length > 0 || artifacts.some((artifact) => artifact.kind === "documentation"),
177
+ };
178
+ const omittedStages = Object.entries(stages)
179
+ .filter(([, enabled]) => !enabled)
180
+ .map(([name]) => name);
181
+ return {
182
+ schemaVersion: 1,
183
+ units,
184
+ publishBatches,
185
+ nodePackages,
186
+ pythonPackages,
187
+ rustPackages,
188
+ artifacts,
189
+ rustTargets,
190
+ stages,
191
+ omittedStages,
192
+ };
193
+ }
194
+
195
+ /** Reconstruct a one-component plan for an idempotent manual recovery. */
196
+ export function buildRecoveryReleasePlan(
197
+ current: ReleaseUnitGraph,
198
+ component: string,
199
+ version: string,
200
+ ): ReleasePlan {
201
+ const unit = current.units.find(
202
+ (candidate) => candidate.component === component || candidate.id === component,
203
+ );
204
+ if (!unit) throw new Error(`Unknown recovery component ${component}`);
205
+ if (unit.version !== version) {
206
+ throw new Error(`Recovery version ${version} does not match ${unit.component}@${unit.version}`);
207
+ }
208
+ const previous: ReleaseUnitGraph = {
209
+ ...current,
210
+ units: current.units.map((candidate) =>
211
+ candidate.id === unit.id
212
+ ? { ...candidate, version: "0.0.0", sourceHash: candidate.sourceHash }
213
+ : candidate,
214
+ ),
215
+ };
216
+ return buildReleasePlan(current, previous);
217
+ }
218
+
219
+ /** Validate that graph units and package identities are internally consistent. */
220
+ export function validateReleasePlanGraph(graph: ReleaseUnitGraph): void {
221
+ const units = new Map<string, ReleaseUnit>();
222
+ for (const unit of graph.units) {
223
+ if (units.has(unit.id)) throw new Error(`Duplicate release plan unit ${unit.id}`);
224
+ units.set(unit.id, unit);
225
+ }
226
+ for (const project of graph.projects) {
227
+ if (project.unit && !units.has(project.unit)) {
228
+ throw new Error(`Release project ${project.identity} has unknown unit ${project.unit}`);
229
+ }
230
+ }
231
+ for (const artifact of graph.artifacts) {
232
+ if (!units.has(artifact.unit)) {
233
+ throw new Error(`Release artifact ${artifact.id} has unknown unit ${artifact.unit}`);
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Release Please integration for Projen-owned polyglot release units.
3
+ *
4
+ * Release Please remains responsible for semantic version selection, release
5
+ * pull requests, tags, and GitHub Releases. This plugin contributes only the
6
+ * dependency propagation encoded by the generated release-unit graph.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { Commit } from "release-please/build/src/commit.js";
12
+ import type {
13
+ CandidateReleasePullRequest,
14
+ RepositoryConfig,
15
+ } from "release-please/build/src/manifest.js";
16
+ import { ManifestPlugin } from "release-please/build/src/plugin.js";
17
+ import type { Release } from "release-please/build/src/release.js";
18
+ import type { Scm } from "release-please/build/src/scm.js";
19
+ import type { Strategy } from "release-please/build/src/strategy.js";
20
+ import { CompositeUpdater } from "release-please/build/src/updaters/composite.js";
21
+ import { ReleasePleaseManifest } from "release-please/build/src/updaters/release-please-manifest.js";
22
+ import { BranchName } from "release-please/build/src/util/branch-name.js";
23
+ import { PullRequestBody } from "release-please/build/src/util/pull-request-body.js";
24
+ import { PullRequestTitle } from "release-please/build/src/util/pull-request-title.js";
25
+ import { Version } from "release-please/build/src/version.js";
26
+ import { PatchVersionUpdate } from "release-please/build/src/versioning-strategy.js";
27
+ import { satisfies, validRange } from "semver";
28
+ import type { ReleaseDependencyEdge, ReleaseUnit, ReleaseUnitGraph } from "./release-catalog.ts";
29
+
30
+ /** Options for {@link ReleaseUnitWorkspacePlugin}. */
31
+ export interface ReleaseUnitWorkspacePluginOptions {
32
+ readonly graph: ReleaseUnitGraph;
33
+ readonly manifestPath?: string;
34
+ readonly merge?: boolean;
35
+ }
36
+
37
+ /**
38
+ * Propagate Release Please candidates through the cross-language release graph.
39
+ *
40
+ * Direct candidates retain the semantic increment selected from their commits.
41
+ * A dependent introduced only by graph propagation receives a patch.
42
+ */
43
+ export class ReleaseUnitWorkspacePlugin extends ManifestPlugin {
44
+ private readonly graph: ReleaseUnitGraph;
45
+ private readonly manifestPath: string;
46
+ private readonly merge: boolean;
47
+ private strategiesByPath: Record<string, Strategy> = {};
48
+ private releasesByPath: Record<string, Release> = {};
49
+
50
+ constructor(
51
+ github: Scm,
52
+ targetBranch: string,
53
+ repositoryConfig: RepositoryConfig,
54
+ options: ReleaseUnitWorkspacePluginOptions,
55
+ ) {
56
+ super(github, targetBranch, repositoryConfig);
57
+ this.graph = options.graph;
58
+ this.manifestPath = options.manifestPath ?? ".release-please-manifest.json";
59
+ this.merge = options.merge ?? true;
60
+ }
61
+
62
+ public override async run(
63
+ candidates: CandidateReleasePullRequest[],
64
+ ): Promise<CandidateReleasePullRequest[]> {
65
+ const unitsById = new Map(this.graph.units.map((unit) => [unit.id, unit]));
66
+ const unitsByPath = new Map(this.graph.units.map((unit) => [releaseUnitPath(unit), unit]));
67
+ const inScope: CandidateReleasePullRequest[] = [];
68
+ const outOfScope: CandidateReleasePullRequest[] = [];
69
+ const versions = new Map<string, Version>();
70
+ const direct = new Set<string>();
71
+
72
+ for (const candidate of candidates) {
73
+ const unit = unitsByPath.get(candidate.path);
74
+ if (!unit || !candidate.pullRequest.version) {
75
+ outOfScope.push(candidate);
76
+ continue;
77
+ }
78
+ inScope.push(candidate);
79
+ direct.add(unit.id);
80
+ versions.set(unit.id, candidate.pullRequest.version);
81
+ }
82
+ if (inScope.length === 0) return candidates;
83
+
84
+ const queue = [...direct];
85
+ const propagated = new Set<string>();
86
+ while (queue.length > 0) {
87
+ const changed = queue.shift()!;
88
+ const changedVersion = versions.get(changed);
89
+ if (!changedVersion) continue;
90
+ for (const edge of this.graph.edges.filter((candidate) => candidate.to === changed)) {
91
+ if (!shouldPropagate(edge, changedVersion, unitsById.get(changed))) continue;
92
+ if (versions.has(edge.from)) continue;
93
+ const dependent = unitsById.get(edge.from);
94
+ if (!dependent) throw new Error(`Release graph references unknown unit ${edge.from}`);
95
+ versions.set(dependent.id, new PatchVersionUpdate().bump(Version.parse(dependent.version)));
96
+ propagated.add(dependent.id);
97
+ queue.push(dependent.id);
98
+ }
99
+ }
100
+
101
+ for (const id of propagated) {
102
+ const unit = unitsById.get(id)!;
103
+ inScope.push(await this.newCandidate(unit, versions.get(id)!));
104
+ }
105
+
106
+ const merged = this.merge ? mergeCandidates(inScope, this.targetBranch) : inScope;
107
+ const manifestVersions = new Map(
108
+ [...versions].map(([id, version]) => {
109
+ const unit = unitsById.get(id);
110
+ if (!unit) throw new Error(`Release version resolved for unknown unit ${id}`);
111
+ return [releaseUnitPath(unit), version] as const;
112
+ }),
113
+ );
114
+ const first = merged[0];
115
+ if (first) {
116
+ const manifestVersion = versions.values().next().value;
117
+ if (!manifestVersion) throw new Error("Release Please resolved no component versions");
118
+ const updater = new ReleasePleaseManifest({
119
+ version: manifestVersion,
120
+ versionsMap: manifestVersions,
121
+ });
122
+ const existing = first.pullRequest.updates.find(
123
+ (update) => update.path === this.manifestPath,
124
+ );
125
+ if (existing) {
126
+ existing.updater = new CompositeUpdater(existing.updater, updater);
127
+ } else {
128
+ first.pullRequest.updates.push({
129
+ path: this.manifestPath,
130
+ createIfMissing: false,
131
+ updater,
132
+ });
133
+ }
134
+ }
135
+ return [...outOfScope, ...merged];
136
+ }
137
+
138
+ public override async preconfigure(
139
+ strategiesByPath: Record<string, Strategy>,
140
+ _commitsByPath: Record<string, Commit[]>,
141
+ releasesByPath: Record<string, Release>,
142
+ ): Promise<Record<string, Strategy>> {
143
+ this.strategiesByPath = strategiesByPath;
144
+ this.releasesByPath = releasesByPath;
145
+ return strategiesByPath;
146
+ }
147
+
148
+ private async newCandidate(
149
+ unit: ReleaseUnit,
150
+ version: Version,
151
+ ): Promise<CandidateReleasePullRequest> {
152
+ const path = releaseUnitPath(unit);
153
+ const strategy = this.strategiesByPath[path];
154
+ if (!strategy) throw new Error(`Release Please strategy not found for ${path}`);
155
+ const pullRequest = await strategy.buildReleasePullRequest(
156
+ [],
157
+ this.releasesByPath[path],
158
+ false,
159
+ [],
160
+ { newVersion: version },
161
+ );
162
+ if (!pullRequest) throw new Error(`Release Please did not build a candidate for ${unit.id}`);
163
+ return {
164
+ path,
165
+ pullRequest,
166
+ config: this.repositoryConfig[path] ?? { releaseType: "simple" },
167
+ };
168
+ }
169
+ }
170
+
171
+ /** Attach graph propagation to a Release Please manifest instance. */
172
+ export function addReleaseUnitPlugin(
173
+ plugins: ManifestPlugin[],
174
+ github: Scm,
175
+ targetBranch: string,
176
+ repositoryConfig: RepositoryConfig,
177
+ graph: ReleaseUnitGraph,
178
+ manifestPath?: string,
179
+ ): void {
180
+ plugins.push(
181
+ new ReleaseUnitWorkspacePlugin(github, targetBranch, repositoryConfig, {
182
+ graph,
183
+ manifestPath,
184
+ }),
185
+ );
186
+ }
187
+
188
+ /** Stable synthetic component path consumed by Release Please. */
189
+ export function releaseUnitPath(unit: Pick<ReleaseUnit, "component">): string {
190
+ return `.release-units/${unit.component}`;
191
+ }
192
+
193
+ function shouldPropagate(
194
+ edge: ReleaseDependencyEdge,
195
+ newVersion: Version,
196
+ dependency: ReleaseUnit | undefined,
197
+ ): boolean {
198
+ if (edge.propagation === "never") return false;
199
+ if (edge.propagation === "always") return true;
200
+ if (!edge.requirement || !dependency) return true;
201
+ let requirement = edge.requirement;
202
+ if (requirement.startsWith("workspace:")) {
203
+ const workspaceRange = requirement.slice("workspace:".length);
204
+ if (workspaceRange === "*") return true;
205
+ requirement = workspaceRange ? `${workspaceRange}${dependency.version}` : dependency.version;
206
+ }
207
+ requirement = requirement.replace(/^==/, "=").replaceAll(",", " ");
208
+ if (!validRange(requirement)) return true;
209
+ return !satisfies(newVersion.toString(), requirement, { includePrerelease: true });
210
+ }
211
+
212
+ function mergeCandidates(
213
+ candidates: CandidateReleasePullRequest[],
214
+ targetBranch: string,
215
+ ): CandidateReleasePullRequest[] {
216
+ if (candidates.length <= 1) return candidates;
217
+ const releaseData = candidates.flatMap((candidate) => candidate.pullRequest.body.releaseData);
218
+ const labels = [...new Set(candidates.flatMap((candidate) => candidate.pullRequest.labels))];
219
+ const rawUpdates = candidates.flatMap((candidate) => candidate.pullRequest.updates);
220
+ const updatesByPath = new Map<string, (typeof rawUpdates)[number]>();
221
+ for (const update of rawUpdates) {
222
+ const current = updatesByPath.get(update.path);
223
+ updatesByPath.set(update.path, {
224
+ ...update,
225
+ updater: current ? new CompositeUpdater(current.updater, update.updater) : update.updater,
226
+ });
227
+ }
228
+ return [
229
+ {
230
+ path: ".",
231
+ pullRequest: {
232
+ title: PullRequestTitle.ofTargetBranch(targetBranch),
233
+ body: new PullRequestBody(releaseData, { useComponents: true }),
234
+ updates: [...updatesByPath.values()],
235
+ labels,
236
+ headRefName: BranchName.ofTargetBranch(targetBranch).toString(),
237
+ draft: !candidates.some((candidate) => !candidate.pullRequest.draft),
238
+ },
239
+ config: { releaseType: "simple" },
240
+ },
241
+ ];
242
+ }