@dbx-tools/projen 0.6.228 → 0.6.230

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/tasks/publish.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  publishedNpmRelease,
74
74
  readNpmArchiveIdentity,
75
75
  } from "./publish-npm.ts";
76
+ import type { ReleasePlan } from "../src/release-plan.ts";
76
77
 
77
78
  const logger = log.logger("dbx-tools:publish");
78
79
 
@@ -104,22 +105,23 @@ function workspaceMembers(root: string): string[] {
104
105
  return (doc?.packages ?? []).map((m) => resolve(root, m));
105
106
  }
106
107
 
107
- /** Whether every workspace member already carries the requested release version. */
108
- function manifestsMatchVersion(members: readonly string[], version: string): boolean {
108
+ /** Whether selected workspace members carry their planned versions. */
109
+ function manifestsMatchVersions(
110
+ root: string,
111
+ members: readonly string[],
112
+ expected: ReadonlyMap<string, string>,
113
+ ): boolean {
109
114
  return members.every((dir) => {
110
115
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
111
116
  version?: string;
112
117
  };
113
- return pkg.version === version;
118
+ const path = dir.slice(resolve(root).length + 1).replaceAll("\\", "/");
119
+ return pkg.version === expected.get(path);
114
120
  });
115
121
  }
116
122
 
117
- /** Whether Bun's workspace lock records the requested version for every member. */
118
- function lockfileMatchesVersion(
119
- root: string,
120
- members: readonly string[],
121
- version: string,
122
- ): boolean {
123
+ /** Whether Bun's workspace lock records each live workspace manifest version. */
124
+ function lockfileMatchesManifestVersions(root: string, members: readonly string[]): boolean {
123
125
  const lockfile = join(root, "bun.lock");
124
126
  if (!existsSync(lockfile)) return false;
125
127
  try {
@@ -129,11 +131,14 @@ function lockfileMatchesVersion(
129
131
  workspaces?: Record<string, { version?: string }>;
130
132
  };
131
133
  return members.every((dir) => {
134
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
135
+ version?: string;
136
+ };
132
137
  const relative = dir
133
138
  .slice(resolve(root).length + 1)
134
139
  .split("\\")
135
140
  .join("/");
136
- return lock.workspaces?.[relative]?.version === version;
141
+ return lock.workspaces?.[relative]?.version === manifest.version;
137
142
  });
138
143
  } catch {
139
144
  return false;
@@ -256,10 +261,18 @@ function enrichedPath(root: string): string {
256
261
  return current.split(delimiter).includes(binDir) ? current : `${binDir}${delimiter}${current}`;
257
262
  }
258
263
 
259
- const [version, ...rest] = process.argv.slice(2);
260
- if (!version) {
264
+ const argv = process.argv.slice(2);
265
+ const version = argv[0] && !argv[0].startsWith("--") ? argv[0] : undefined;
266
+ const rest = version ? argv.slice(1) : argv;
267
+ const root = process.cwd();
268
+ const planIdx = rest.indexOf("--plan");
269
+ const plan =
270
+ planIdx >= 0
271
+ ? (JSON.parse(readFileSync(resolve(root, rest[planIdx + 1]), "utf8")) as ReleasePlan)
272
+ : undefined;
273
+ if (!version && !plan) {
261
274
  logger.error(
262
- "usage: bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]",
275
+ "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run]",
263
276
  );
264
277
  process.exit(1);
265
278
  }
@@ -278,29 +291,40 @@ const excluded = new Set(
278
291
  rest.reduce<string[]>((acc, arg, i) => (arg === "--exclude" ? [...acc, rest[i + 1]] : acc), []),
279
292
  );
280
293
 
281
- const root = process.cwd();
282
294
  const path = enrichedPath(root);
283
295
  // Registered BEFORE the first manifest edit so no exit path can skip it: a clean
284
296
  // finish and a `bun publish` failure mid-loop both land here, so a publish that
285
297
  // dies partway does not leave half the workspace stamped.
286
298
  if (restore) process.on("exit", restoreManifests);
287
- const members = workspaceMembers(root)
299
+ const allMembers = workspaceMembers(root)
288
300
  .filter((dir) => existsSync(join(dir, "package.json")))
289
301
  .filter((dir) => !excluded.has(resolve(root, dir).replace(`${resolve(root)}/`, "")));
302
+ const expectedVersions = new Map(
303
+ plan
304
+ ? plan.nodePackages.map((pkg) => [pkg.path, pkg.version] as const)
305
+ : allMembers.map((dir) => [
306
+ dir.slice(resolve(root).length + 1).replaceAll("\\", "/"),
307
+ version!,
308
+ ]),
309
+ );
310
+ const members = plan
311
+ ? allMembers.filter((dir) =>
312
+ expectedVersions.has(dir.slice(resolve(root).length + 1).replaceAll("\\", "/")),
313
+ )
314
+ : allMembers;
290
315
 
291
- // Publication consumes the reviewed version state and never repairs it.
292
- if (!manifestsMatchVersion(members, version)) {
293
- throw new Error(`workspace manifests do not all match release version ${version}; run projen`);
316
+ if (!manifestsMatchVersions(root, members, expectedVersions)) {
317
+ throw new Error("workspace manifests do not match the reviewed release plan; run projen");
294
318
  }
295
- logger.info(`all ${members.length} member manifests carry ${version}`);
319
+ logger.info(`validated ${members.length} selected member manifests`);
296
320
 
297
321
  // Ensure the lockfile resolves each `workspace:*` to the release version.
298
322
  // `bun publish`/`pm pack` reads workspace versions from the LOCKFILE, not just
299
323
  // live manifests. A normal bump's synth/install already makes it current; after
300
324
  // fallback stamping, deleting it before install is what forces re-resolution.
301
325
  const lockfile = join(root, "bun.lock");
302
- if (lockfileMatchesVersion(root, members, version)) {
303
- logger.info(`workspace lock already resolves members at ${version}`);
326
+ if (lockfileMatchesManifestVersions(root, allMembers)) {
327
+ logger.info("workspace lock already resolves live member versions");
304
328
  } else {
305
329
  if (existsSync(lockfile)) rmSync(lockfile);
306
330
  logger.info("refreshing lockfile so workspace deps resolve to the release version");
@@ -314,10 +338,16 @@ const publishArgs = [
314
338
  ...(registry ? ["--registry", registry] : []),
315
339
  ...(dryRun ? ["--dry-run"] : []),
316
340
  ];
317
- const publishable: Array<{ dir: string; name: string; compile: boolean }> = [];
341
+ const publishable: Array<{
342
+ dir: string;
343
+ name: string;
344
+ version: string;
345
+ compile: boolean;
346
+ }> = [];
318
347
  for (const dir of members) {
319
348
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
320
349
  name?: string;
350
+ version?: string;
321
351
  private?: boolean;
322
352
  dbxToolsConfig?: { uniffi?: boolean };
323
353
  scripts?: Record<string, string>;
@@ -330,9 +360,11 @@ for (const dir of members) {
330
360
  logger.info(`skip UniFFI ${pkg.name ?? dirname(dir)}`);
331
361
  continue;
332
362
  }
363
+ if (!pkg.version) throw new Error(`Missing package version for ${pkg.name ?? dirname(dir)}`);
333
364
  publishable.push({
334
365
  dir,
335
366
  name: pkg.name ?? dirname(dir),
367
+ version: pkg.version,
336
368
  compile: Boolean(pkg.scripts && typeof pkg.scripts === "object" && "prepack" in pkg.scripts),
337
369
  });
338
370
  }
@@ -355,29 +387,27 @@ for (const { dir } of publishable) {
355
387
  logger.info(
356
388
  `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
357
389
  );
358
- await runConcurrent(publishable, concurrency, async ({ dir, name }) => {
390
+ await runConcurrent(publishable, concurrency, async ({ dir, name, version: packageVersion }) => {
359
391
  if (!dryRun) {
360
392
  const packed = mkdtempSync(join(tmpdir(), "dbx-tools-npm-release-"));
361
393
  try {
362
394
  const archive = packNpmPackage(dir, packed, path);
363
395
  const local = readNpmArchiveIdentity(archive);
364
- if (local.name !== name || local.version !== version) {
396
+ if (local.name !== name || local.version !== packageVersion) {
365
397
  throw new Error(
366
- `Packed npm identity ${local.name}@${local.version} does not match ${name}@${version}`,
398
+ `Packed npm identity ${local.name}@${local.version} does not match ${name}@${packageVersion}`,
367
399
  );
368
400
  }
369
401
  const published = await publishedNpmRelease(local.name, local.version, registry);
370
402
  if (npmReleaseMatches(local, published)) {
371
- logger.info(`skip published ${name} @ ${version}`);
403
+ logger.info(`skip published ${name} @ ${packageVersion}`);
372
404
  return;
373
405
  }
374
406
  } finally {
375
407
  rmSync(packed, { recursive: true, force: true });
376
408
  }
377
409
  }
378
- logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${version}`);
410
+ logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
379
411
  await runAsync(dir, "bun", ["publish", ...publishArgs], path);
380
412
  });
381
- logger.success(
382
- `${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages @ ${version}`,
383
- );
413
+ logger.success(`${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages`);
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Bootstrap Release Please state from the generated release-unit graph. */
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { project } from "@dbx-tools/core";
7
+ import { json, log } from "@dbx-tools/shared-core";
8
+ import { Command } from "commander";
9
+ import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
10
+
11
+ const logger = log.logger("projen:release-bootstrap");
12
+
13
+ function readGraph(root: string): ReleaseUnitGraph {
14
+ const path = join(root, ".projen/release-units.json");
15
+ const graph = json.parse(readFileSync(path, "utf8")) as ReleaseUnitGraph;
16
+ if (graph.schemaVersion !== 1) {
17
+ throw new Error(`Unsupported release-unit graph schema: ${String(graph.schemaVersion)}`);
18
+ }
19
+ return graph;
20
+ }
21
+
22
+ export function bootstrapReleaseUnits(root: string): void {
23
+ const graph = readGraph(root);
24
+ const manifestPath = join(root, ".release-please-manifest.json");
25
+ const expected = Object.fromEntries(
26
+ graph.units.map((unit) => [`.release-units/${unit.component}`, unit.version]),
27
+ );
28
+ if (existsSync(manifestPath)) {
29
+ const current = json.parseRecord(readFileSync(manifestPath, "utf8"));
30
+ for (const [path, version] of Object.entries(expected)) {
31
+ if (current?.[path] !== version) {
32
+ throw new Error(
33
+ `Release Please manifest already contains ${path}=${String(current?.[path])}; expected ${version}`,
34
+ );
35
+ }
36
+ }
37
+ } else {
38
+ writeFileSync(manifestPath, `${JSON.stringify(expected, null, 2)}\n`);
39
+ }
40
+
41
+ for (const unit of graph.units) {
42
+ const directory = join(root, ".release-units", unit.component);
43
+ mkdirSync(directory, { recursive: true });
44
+ const versionPath = join(directory, "version.txt");
45
+ if (!existsSync(versionPath)) writeFileSync(versionPath, `${unit.version}\n`);
46
+ const changelogPath = join(directory, "CHANGELOG.md");
47
+ if (!existsSync(changelogPath)) writeFileSync(changelogPath, "# Changelog\n");
48
+ }
49
+ logger.success(`bootstrapped ${graph.units.length} release units`);
50
+ }
51
+
52
+ if (import.meta.main) {
53
+ new Command()
54
+ .option("--root <path>", "repository root")
55
+ .action((options: { root?: string }) => {
56
+ bootstrapReleaseUnits(resolve(options.root ?? project.root() ?? process.cwd()));
57
+ })
58
+ .parse();
59
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Build the affected release plan for the reviewed manifest change. */
3
+
4
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { exec } from "@dbx-tools/core";
7
+ import { json, log } from "@dbx-tools/shared-core";
8
+ import { Command } from "commander";
9
+ import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
10
+ import { buildRecoveryReleasePlan, buildReleasePlan } from "../src/release-plan.ts";
11
+
12
+ const logger = log.logger("projen:release-plan");
13
+
14
+ function graphAtRef(root: string, ref: string): ReleaseUnitGraph | undefined {
15
+ const result = exec.spawnSync("git", ["show", `${ref}:.projen/release-units.json`], {
16
+ cwd: root,
17
+ stdout: "capture",
18
+ stderr: "ignore",
19
+ stdin: "ignore",
20
+ check: false,
21
+ });
22
+ if (result.exitCode !== 0 || !result.stdout) return undefined;
23
+ return json.parse(result.stdout) as ReleaseUnitGraph;
24
+ }
25
+
26
+ function writeOutput(name: string, value: unknown): void {
27
+ const output = process.env.GITHUB_OUTPUT;
28
+ if (output) {
29
+ const rendered = typeof value === "string" ? value : JSON.stringify(value);
30
+ appendFileSync(output, `${name}=${rendered}\n`);
31
+ }
32
+ }
33
+
34
+ new Command()
35
+ .option("--root <path>", "repository root", ".")
36
+ .option("--base-ref <ref>", "previous release graph ref", "HEAD^")
37
+ .option("--component <component>", "component to recover")
38
+ .option("--version <version>", "component version to recover")
39
+ .option("--docs", "include documentation without a package release")
40
+ .option("--output <path>", "release plan output", "dist/release-plan.json")
41
+ .action(
42
+ (options: {
43
+ root: string;
44
+ baseRef: string;
45
+ output: string;
46
+ component?: string;
47
+ version?: string;
48
+ docs?: boolean;
49
+ }) => {
50
+ const root = resolve(options.root);
51
+ const current = json.parse(
52
+ readFileSync(resolve(root, ".projen/release-units.json"), "utf8"),
53
+ ) as ReleaseUnitGraph;
54
+ if (Boolean(options.component) !== Boolean(options.version)) {
55
+ throw new Error("Recovery requires both --component and --version");
56
+ }
57
+ const planned =
58
+ options.component && options.version
59
+ ? buildRecoveryReleasePlan(current, options.component, options.version)
60
+ : buildReleasePlan(current, graphAtRef(root, options.baseRef));
61
+ const plan = options.docs
62
+ ? {
63
+ ...planned,
64
+ stages: { ...planned.stages, docs: true },
65
+ omittedStages: planned.omittedStages.filter((stage) => stage !== "docs"),
66
+ }
67
+ : planned;
68
+ const output = resolve(root, options.output);
69
+ mkdirSync(dirname(output), { recursive: true });
70
+ writeFileSync(output, `${JSON.stringify(plan, null, 2)}\n`);
71
+ writeOutput("plan", plan);
72
+ writeOutput("release_sha", process.env.GITHUB_SHA ?? "");
73
+ writeOutput("units", plan.units);
74
+ writeOutput("node_packages", plan.nodePackages);
75
+ writeOutput("python_packages", plan.pythonPackages);
76
+ writeOutput("rust_packages", plan.rustPackages);
77
+ writeOutput("artifacts", plan.artifacts);
78
+ writeOutput("rust_targets", plan.rustTargets);
79
+ writeOutput("stages", plan.stages);
80
+ for (const [stage, enabled] of Object.entries(plan.stages)) {
81
+ writeOutput(stage, enabled);
82
+ }
83
+ logger.success("release plan generated", {
84
+ units: plan.units.length,
85
+ omittedStages: plan.omittedStages,
86
+ });
87
+ },
88
+ )
89
+ .parse();
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Run Release Please with the generated cross-language release-unit plugin. */
3
+
4
+ import { appendFileSync, readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { exec } from "@dbx-tools/core";
7
+ import { log } from "@dbx-tools/shared-core";
8
+ import { Command } from "commander";
9
+ import { GitHub } from "release-please";
10
+ import { Manifest } from "release-please/build/src/manifest.js";
11
+ import type { Scm } from "release-please/build/src/scm.js";
12
+ import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
13
+ import { addReleaseUnitPlugin } from "../src/release-please.ts";
14
+
15
+ const logger = log.logger("projen:release-please");
16
+
17
+ export interface ReleasePleaseRunOptions {
18
+ readonly github: Scm;
19
+ readonly targetBranch: string;
20
+ readonly graph: ReleaseUnitGraph;
21
+ readonly configFile?: string;
22
+ readonly manifestFile?: string;
23
+ }
24
+
25
+ /** Create component releases and refresh the combined release pull request. */
26
+ export async function runReleasePlease(options: ReleasePleaseRunOptions) {
27
+ const configFile = options.configFile ?? "release-please-config.json";
28
+ const manifestFile = options.manifestFile ?? ".release-please-manifest.json";
29
+ const manifest = await Manifest.fromManifest(
30
+ options.github,
31
+ options.targetBranch,
32
+ configFile,
33
+ manifestFile,
34
+ );
35
+ addReleaseUnitPlugin(
36
+ manifest.plugins,
37
+ options.github,
38
+ options.targetBranch,
39
+ manifest.repositoryConfig,
40
+ options.graph,
41
+ manifestFile,
42
+ );
43
+ const releases = (await manifest.createReleases()).filter(
44
+ (release): release is NonNullable<typeof release> => release !== undefined,
45
+ );
46
+ const pullRequests = (await manifest.createPullRequests()).filter(
47
+ (pullRequest): pullRequest is NonNullable<typeof pullRequest> => pullRequest !== undefined,
48
+ );
49
+ return { releases, pullRequests };
50
+ }
51
+
52
+ function writeOutput(name: string, value: unknown): void {
53
+ const output = process.env.GITHUB_OUTPUT;
54
+ if (!output) return;
55
+ appendFileSync(output, `${name}=${JSON.stringify(value)}\n`);
56
+ }
57
+
58
+ /** Parse GitHub owner and repository from HTTPS, SSH, or SSH-alias syntax. */
59
+ export function parseGitHubRepository(remote: string): { owner: string; repo: string } {
60
+ const location = remote
61
+ .replace(/^git@[^:]+:/, "")
62
+ .replace(/^ssh:\/\/git@[^/]+\//, "")
63
+ .replace(/^https?:\/\/[^/]+\//, "")
64
+ .replace(/\.git$/, "");
65
+ const [owner, repo, ...extra] = location.split("/");
66
+ if (!owner || !repo || extra.length > 0) {
67
+ throw new Error(`Could not resolve GitHub repository from ${remote}`);
68
+ }
69
+ return { owner, repo };
70
+ }
71
+
72
+ /** Resolve GitHub owner and repository from environment or the configured remote. */
73
+ export function repositoryCoordinates(root: string): { owner: string; repo: string } {
74
+ const configured = process.env.GITHUB_REPOSITORY;
75
+ const remote = configured
76
+ ? configured
77
+ : (exec
78
+ .spawnSync("git", ["remote", "get-url", "origin"], {
79
+ cwd: root,
80
+ stdout: "capture",
81
+ stderr: "ignore",
82
+ stdin: "ignore",
83
+ check: true,
84
+ })
85
+ .stdout?.trim() ?? "");
86
+ return parseGitHubRepository(remote);
87
+ }
88
+
89
+ function githubToken(): string {
90
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
91
+ return (
92
+ exec
93
+ .spawnSync("gh", ["auth", "token"], {
94
+ stdout: "capture",
95
+ stderr: "inherit",
96
+ stdin: "ignore",
97
+ check: true,
98
+ })
99
+ .stdout?.trim() ?? ""
100
+ );
101
+ }
102
+
103
+ if (import.meta.main) {
104
+ await new Command()
105
+ .option("--owner <owner>", "GitHub repository owner")
106
+ .option("--repo <repo>", "GitHub repository name")
107
+ .option("--target-branch <branch>", "release target branch", "main")
108
+ .option("--root <path>", "repository root", ".")
109
+ .option("--config-file <path>", "Release Please config", "release-please-config.json")
110
+ .option(
111
+ "--manifest-file <path>",
112
+ "Release Please version manifest",
113
+ ".release-please-manifest.json",
114
+ )
115
+ .option("--approve", "compatibility alias for the Release Please release pass")
116
+ .action(
117
+ async (options: {
118
+ owner?: string;
119
+ repo?: string;
120
+ targetBranch: string;
121
+ root: string;
122
+ configFile: string;
123
+ manifestFile: string;
124
+ }) => {
125
+ const root = resolve(options.root);
126
+ const repository =
127
+ options.owner && options.repo
128
+ ? { owner: options.owner, repo: options.repo }
129
+ : repositoryCoordinates(root);
130
+ const graph = JSON.parse(
131
+ readFileSync(resolve(root, ".projen/release-units.json"), "utf8"),
132
+ ) as ReleaseUnitGraph;
133
+ const github = await GitHub.create({
134
+ owner: repository.owner,
135
+ repo: repository.repo,
136
+ defaultBranch: options.targetBranch,
137
+ token: githubToken(),
138
+ ...(process.env.GITHUB_API_URL ? { apiUrl: process.env.GITHUB_API_URL } : {}),
139
+ ...(process.env.GITHUB_GRAPHQL_URL
140
+ ? {
141
+ graphqlUrl: process.env.GITHUB_GRAPHQL_URL.replace(/\/graphql\/?$/, ""),
142
+ }
143
+ : {}),
144
+ });
145
+ const result = await runReleasePlease({
146
+ github,
147
+ targetBranch: options.targetBranch,
148
+ graph,
149
+ configFile: options.configFile,
150
+ manifestFile: options.manifestFile,
151
+ });
152
+ logger.success("release state refreshed", {
153
+ releases: result.releases.length,
154
+ pullRequests: result.pullRequests.length,
155
+ });
156
+ writeOutput("releases_created", result.releases.length > 0);
157
+ writeOutput("prs_created", result.pullRequests.length > 0);
158
+ writeOutput("releases", result.releases);
159
+ writeOutput("prs", result.pullRequests);
160
+ },
161
+ )
162
+ .parseAsync();
163
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Generate component-qualified release notes from an affected release plan. */
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { log } from "@dbx-tools/shared-core";
7
+ import { Command } from "commander";
8
+ import { generateReleaseSummary } from "./release-summary.ts";
9
+ import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
10
+ import type { ReleasePlan } from "../src/release-plan.ts";
11
+
12
+ const logger = log.logger("projen:release-summary-units");
13
+
14
+ if (import.meta.main) {
15
+ await new Command()
16
+ .option("--root <path>", "repository root", ".")
17
+ .option("--plan <path>", "affected release plan", "dist/release-plan.json")
18
+ .option("--from-ref <ref>", "summary base ref", "origin/main")
19
+ .option("--to-ref <ref>", "summary target ref", "HEAD")
20
+ .action(async (options: { root: string; plan: string; fromRef: string; toRef: string }) => {
21
+ const root = resolve(options.root);
22
+ const plan = JSON.parse(readFileSync(resolve(root, options.plan), "utf8")) as ReleasePlan;
23
+ const graph = JSON.parse(
24
+ readFileSync(resolve(root, ".projen/release-units.json"), "utf8"),
25
+ ) as ReleaseUnitGraph;
26
+ for (const planned of plan.units) {
27
+ const unit = graph.units.find((candidate) => candidate.id === planned.id);
28
+ if (!unit) throw new Error(`Release plan references unknown unit ${planned.id}`);
29
+ const paths = graph.projects
30
+ .filter((project) => project.unit === unit.id)
31
+ .map((project) => project.path);
32
+ await generateReleaseSummary({
33
+ root,
34
+ component: planned.component,
35
+ version: planned.newVersion,
36
+ paths,
37
+ fromRef: options.fromRef,
38
+ toRef: options.toRef,
39
+ });
40
+ }
41
+ logger.success(`generated ${plan.units.length} component release summaries`);
42
+ })
43
+ .parseAsync();
44
+ }
@@ -15,6 +15,8 @@ import {
15
15
  } from "../src/release-dispatch.ts";
16
16
 
17
17
  const logger = log.logger("projen:release-summary");
18
+ const PROVIDER_TIMEOUT_MS = 60_000;
19
+ const PROVIDER_KILL_GRACE_MS = 5_000;
18
20
 
19
21
  export interface ReleaseSummaryProvider {
20
22
  readonly name: ReleaseSummaryProviderName;
@@ -132,7 +134,7 @@ async function runProvider(
132
134
  if (!capture(root, provider.command, [...provider.probeArgs])) return undefined;
133
135
  let summary: string | undefined;
134
136
  const textOutput: string[] = [];
135
- const process = exec.spawn(provider.command, provider.args(root, prompt), {
137
+ const child = exec.spawn(provider.command, provider.args(root, prompt), {
136
138
  cwd: root,
137
139
  stdout: [
138
140
  "capture",
@@ -164,7 +166,21 @@ async function runProvider(
164
166
  stdin: "ignore",
165
167
  check: false,
166
168
  });
167
- const result = await process;
169
+ let escalation: ReturnType<typeof setTimeout> | undefined;
170
+ const timeout = setTimeout(() => {
171
+ logger.warn("provider-timeout", {
172
+ provider: provider.name,
173
+ timeoutMs: PROVIDER_TIMEOUT_MS,
174
+ });
175
+ child.kill("SIGTERM");
176
+ escalation = setTimeout(() => child.kill("SIGKILL"), PROVIDER_KILL_GRACE_MS);
177
+ escalation.unref();
178
+ }, PROVIDER_TIMEOUT_MS);
179
+ timeout.unref();
180
+ const result = await child.finally(() => {
181
+ clearTimeout(timeout);
182
+ if (escalation) clearTimeout(escalation);
183
+ });
168
184
  if (result.exitCode !== 0) {
169
185
  logger.warn("provider-failed", { provider: provider.name, exitCode: result.exitCode });
170
186
  return undefined;
@@ -247,12 +263,14 @@ function gitSummary(version: string, commits: string, changedFiles: string): str
247
263
  export async function generateReleaseSummary(options: {
248
264
  readonly root: string;
249
265
  readonly version: string;
266
+ readonly component?: string;
267
+ readonly paths?: readonly string[];
250
268
  readonly fromRef?: string;
251
269
  readonly toRef?: string;
252
270
  readonly providers?: readonly ReleaseSummaryProviderName[];
253
271
  readonly runner?: ReleaseSummaryRunner;
254
272
  }): Promise<string | undefined> {
255
- const relativeOutput = releaseSummaryFile(options.version);
273
+ const relativeOutput = releaseSummaryFile(options.version, options.component);
256
274
  const output = join(options.root, relativeOutput);
257
275
  if (existsSync(output)) rmSync(output);
258
276
  const target =
@@ -263,13 +281,21 @@ export async function generateReleaseSummary(options: {
263
281
  options.fromRef && capture(options.root, "git", ["rev-parse", "--verify", options.fromRef])
264
282
  ? `${options.fromRef}..${target}`
265
283
  : target;
266
- const commits = capture(options.root, "git", ["log", "--no-merges", "--format=%s%n%b", range]);
284
+ const scopedPaths = options.paths?.length ? [...options.paths] : ["."];
285
+ const commits = capture(options.root, "git", [
286
+ "log",
287
+ "--no-merges",
288
+ "--format=%s%n%b",
289
+ range,
290
+ "--",
291
+ ...scopedPaths,
292
+ ]);
267
293
  const changedFiles = capture(options.root, "git", [
268
294
  "diff",
269
295
  "--name-status",
270
296
  range,
271
297
  "--",
272
- ".",
298
+ ...scopedPaths,
273
299
  `:(exclude)${relativeOutput}`,
274
300
  ]);
275
301
  const diffStat = capture(options.root, "git", [
@@ -277,7 +303,7 @@ export async function generateReleaseSummary(options: {
277
303
  "--stat",
278
304
  range,
279
305
  "--",
280
- ".",
306
+ ...scopedPaths,
281
307
  `:(exclude)${relativeOutput}`,
282
308
  ]);
283
309
  const result = await selectReleaseSummary(
@@ -289,7 +315,7 @@ export async function generateReleaseSummary(options: {
289
315
  const summary = result?.summary ?? gitSummary(options.version, commits, changedFiles);
290
316
  const provider = result?.provider ?? "git";
291
317
  if (!result) logger.info("AI providers unavailable; using Git release summary");
292
- const content = `# Release ${options.version}\n\n${summary.trim()}\n`;
318
+ const content = `# Release ${options.component ? `${options.component} ` : ""}${options.version}\n\n${summary.trim()}\n`;
293
319
  mkdirSync(dirname(output), { recursive: true });
294
320
  writeFileSync(output, content);
295
321
  logger.info("generated", { provider, path: output });
@@ -6,11 +6,15 @@ export interface TomlCodec {
6
6
  export interface PythonPackageMapping {
7
7
  readonly directory: string;
8
8
  readonly name: string;
9
+ readonly version: string;
10
+ readonly uniffi: boolean;
9
11
  }
10
12
 
11
13
  export interface PythonProjectInfo {
12
14
  readonly name: string;
15
+ readonly version: string;
13
16
  readonly private: boolean;
17
+ readonly uniffi: boolean;
14
18
  }
15
19
 
16
20
  /** Read the project identity and publication flag from parsed TOML metadata. */
@@ -22,6 +26,7 @@ export function stampPythonProject(
22
26
  options: {
23
27
  readonly packages: readonly PythonPackageMapping[];
24
28
  readonly rewriteDependencies?: boolean;
29
+ readonly usePackageVersions?: boolean;
25
30
  readonly toml: TomlCodec;
26
31
  readonly version: string;
27
32
  },