@dbx-tools/projen 0.9.167 → 0.9.174

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 CHANGED
@@ -626,9 +626,14 @@ and profile are not recorded in the annotated tag.
626
626
 
627
627
  After bump and configured validation, release writes
628
628
  `docs/releases/vX.Y.Z.md` with `dbx genie exec -C "$PWD" --sandbox read-only
629
- --ephemeral -o`. The standard prompt cannot run validation commands. Failures
630
- fall back to a short git-log summary. Notes are committed with the version bump,
631
- not stored in the annotated tag, and are omitted for an existing committed bump.
629
+ --ephemeral -o`. It first finds the newest tag whose GitHub Actions release
630
+ workflow succeeded, then asks Genie for cumulative notes from that deployed
631
+ baseline through HEAD. Newer failed-release tags and their notes remain source
632
+ material instead of shortening the delta to a retry-only workflow fix. The
633
+ standard prompt cannot run validation commands. Failures fall back to a short
634
+ git-log summary from the same successful baseline. Notes are committed with the
635
+ version bump, not stored in the annotated tag, and are omitted for an existing
636
+ committed bump.
632
637
 
633
638
  Automatic installation retains Projen's manifest-based install trigger; the
634
639
  other install modes force installation or reuse the current dependency tree.
@@ -664,6 +669,24 @@ whole-tree operation driven by the root `.projenrc.ts`, so a member-level run
664
669
  re-synths the entire workspace from the member's directory. Run `bun run sync`
665
670
  from the root instead.
666
671
 
672
+ ## Commit a registry-neutral lockfile
673
+
674
+ The root `bun.lock` is committed, and generated CI installs run
675
+ `bun install --frozen-lockfile`. A private npm mirror would otherwise appear in
676
+ every resolved tarball URL, so the generated `bun-lockfile` task runs after each
677
+ root install and as the root `postinstall` script, which also covers `bun add`,
678
+ `bun remove`, and `bun update`. It detects the active registry from the same
679
+ environment, `.npmrc`, `bunfig.toml`, and pnpm configuration a package manager
680
+ reads. When that registry is not public npm, it rewrites every tarball URL under
681
+ it and leaves all other URLs unchanged. Bun 1.4.2 has no native setting that
682
+ omits registry URLs from `bun.lock`.
683
+
684
+ The default replacement is an empty tarball field, which Bun resolves through
685
+ the installing machine's registry; integrity hashes still pin the content.
686
+ Run `bun run bun-lockfile -- --check` to confirm a lockfile is clean without
687
+ writing it. Options can also come from `PROJEN_BUN_LOCKFILE_*` environment
688
+ variables or `.env`.
689
+
667
690
  ## Manage workspace versions
668
691
 
669
692
  `VERSION` is the language-neutral version seam. Real package manifests remain
package/index.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.167";
6
+ export const PACKAGE_VERSION = "0.9.174";
7
7
  export * as appkitWorkspace from "./src/appkit-workspace.ts";
8
8
  export * as barrels from "./src/barrels.ts";
9
9
  export * as bunApp from "./src/bun-app.ts";
10
+ export * as bunLockfile from "./src/bun-lockfile.ts";
10
11
  export * as bunWorkflow from "./src/bun-workflow.ts";
11
12
  export * as clean from "./src/clean.ts";
12
13
  export * as codegen from "./src/codegen.ts";
@@ -48,6 +49,8 @@ export type { AppKitWorkspaceMode, AppKitBundleOptions, AppKitWorkspaceOptions }
48
49
  export { isGeneratedBarrel, folderBarrelFile, generateBarrels } from "./src/barrels.ts";
49
50
  export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
50
51
  export type { BunBuildFileOptions } from "./src/bun-app.ts";
52
+ export { BUN_LOCKFILE, BunLockfileReplacementSchema, BunLockfileOptionsSchema, bunLockfileRegistries, normalizeBunLockfile, normalizeBunLockfileAt } from "./src/bun-lockfile.ts";
53
+ export type { BunLockfileOptions, BunLockfileOptionsInput, NormalizedBunLockfile, BunLockfileNormalization } from "./src/bun-lockfile.ts";
51
54
  export { BUN_VERSION, MINIMUM_BUN_VERSION, BUN_VERSION_CHECK_SCRIPT } from "./src/bun-workflow.ts";
52
55
  export { listGeneratedFiles, listNodeModulesDirs, removePaths } from "./src/clean.ts";
53
56
  export { codegenModulePaths, generateCodegen } from "./src/codegen.ts";
@@ -68,7 +71,7 @@ export { resolveRepoRoot, repoRoot, DEFAULT_PACKAGE_ROOTS, projectName, toPosix,
68
71
  export type { RecordedPackage } from "./src/packages.ts";
69
72
  export { PnpmWorkspaceState } from "./src/pnpm-workspace.ts";
70
73
  export type { Catalog, AllowBuilds, DBXToolsPNPMWorkspaceOptions } from "./src/pnpm-workspace.ts";
71
- export { PackageIdentifier, identifier, applyCompilerOptions, applyIncludes, applyExports, addExports, addOptionalPeer, projectRepositoryUrl, projectReleaseBranch, addPackageFiles, srcFolderExports, LICENSE, DBXToolsNodeProject, ROOT_INSTALL_ONLY_MIXIN, DBXToolsTypeScriptProject, taskCommand } from "./src/project-js.ts";
74
+ export { PackageIdentifier, identifier, applyCompilerOptions, applyIncludes, applyExports, addExports, addOptionalPeer, projectRepositoryUrl, projectReleaseBranch, addPackageFiles, srcFolderExports, LICENSE, BUN_LOCKFILE_TASK, DBXToolsNodeProject, ROOT_INSTALL_ONLY_MIXIN, DBXToolsTypeScriptProject, taskCommand } from "./src/project-js.ts";
72
75
  export type { DBXToolsJavaScriptProject, DBXToolsReleaseMode, DiscoveredTypeScriptPackageOptions, DBXToolsJavaScriptProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project-js.ts";
73
76
  export { isProject, isDBXToolsProject, isDBXToolsJavaScriptProject, hasName, hasIdentifierPackageName, hasIdentifierName, hasIdentifierScope, hasTag, hasPath } from "./src/project-predicate.ts";
74
77
  export { pythonPackagePath, pythonModuleName, DBXToolsPythonProject, DBXToolsPythonWorkspace } from "./src/project-py.ts";
package/package.json CHANGED
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/auth": "^0.9.167",
34
- "@dbx-tools/cli-args": "^0.9.167",
35
- "@dbx-tools/core": "^0.9.167",
36
- "@dbx-tools/path": "^0.9.167",
37
- "@dbx-tools/shared-core": "^0.9.167",
33
+ "@dbx-tools/auth": "^0.9.174",
34
+ "@dbx-tools/cli-args": "^0.9.174",
35
+ "@dbx-tools/core": "^0.9.174",
36
+ "@dbx-tools/path": "^0.9.174",
37
+ "@dbx-tools/shared-core": "^0.9.174",
38
38
  "commander": "^15.0.0",
39
39
  "concurrently": "^10.0.3",
40
40
  "constructs": "^10.6.0",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "main": "index.ts",
58
58
  "license": "Apache-2.0",
59
- "version": "0.9.167",
59
+ "version": "0.9.174",
60
60
  "types": "index.ts",
61
61
  "type": "module",
62
62
  "exports": {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Registry-neutral Bun text lockfiles.
3
+ *
4
+ * Bun records each package's resolved tarball URL in `bun.lock`. When the
5
+ * installing machine's npm registry is a private mirror, every URL names that
6
+ * mirror, so a committed lockfile would leak it and fail on machines that cannot
7
+ * reach it. Normalization detects the active registry the same way a package
8
+ * manager does and, when it is not public npm, rewrites every tarball URL under
9
+ * it in bulk. All other URLs are left as they are.
10
+ *
11
+ * Bun accepts both replacement forms under `--frozen-lockfile`: an empty tarball
12
+ * field resolves through whichever registry the installing machine configures,
13
+ * and a public npm URL pins npmjs explicitly. Integrity hashes are unchanged, so
14
+ * frozen installs still verify the exact tarball content.
15
+ *
16
+ * Bun has no native setting for this as of 1.4.2: `[install.lockfile]` only
17
+ * supports `save` and `print`, and Bun's own `BunLockFile` type marks the npm
18
+ * registry field "TODO: remove if default". Replace this rewrite with that
19
+ * native behavior once Bun ships it.
20
+ */
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { npmRegistry } from "@dbx-tools/core";
24
+ import { z } from "zod";
25
+
26
+ /** Bun's text lockfile at a workspace root. */
27
+ export const BUN_LOCKFILE = "bun.lock";
28
+
29
+ /** Public npm registry used by the `npmjs` replacement. */
30
+ const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org";
31
+
32
+ /** Registry tarball entry: `"key": ["name@version", "<tarball>", ...`. */
33
+ const TARBALL_ENTRY =
34
+ /^(\s+"[^"\n]+": \[")((?:@[^/"\n]+\/)?[^@"\n]+)@([^"\n]+)(", )"(https?:\/\/[^"\n]*)"/gm;
35
+
36
+ /** How private registry tarball URLs are rewritten. */
37
+ export const BunLockfileReplacementSchema = z
38
+ .enum(["empty", "npmjs"])
39
+ .describe(
40
+ "Replacement for private registry tarball URLs: empty resolves through the installing machine's registry; npmjs pins public npm",
41
+ );
42
+
43
+ /** Options that control `bun.lock` normalization. */
44
+ export const BunLockfileOptionsSchema = z
45
+ .object({
46
+ mode: z
47
+ .enum(["auto", "off"])
48
+ .default("auto")
49
+ .describe(
50
+ "auto rewrites tarball URLs under the detected npm registry when it is not public npm; off leaves the lockfile unchanged",
51
+ ),
52
+ replacement: BunLockfileReplacementSchema.default("empty"),
53
+ registries: z
54
+ .array(z.string().trim().url())
55
+ .default([])
56
+ .describe("Additional registry URLs whose tarball URLs are rewritten"),
57
+ check: z
58
+ .boolean()
59
+ .default(false)
60
+ .describe("Fail instead of writing when the lockfile is not normalized"),
61
+ })
62
+ .describe("Keep bun.lock free of private npm registry URLs");
63
+
64
+ /** Parsed {@link BunLockfileOptionsSchema}. */
65
+ export type BunLockfileOptions = z.output<typeof BunLockfileOptionsSchema>;
66
+
67
+ /** Accepted input for {@link BunLockfileOptionsSchema}. */
68
+ export type BunLockfileOptionsInput = z.input<typeof BunLockfileOptionsSchema>;
69
+
70
+ /** Result of normalizing one lockfile body. */
71
+ export interface NormalizedBunLockfile {
72
+ /** Lockfile text with private registry tarball URLs rewritten. */
73
+ readonly content: string;
74
+ /** Number of tarball URLs rewritten. */
75
+ readonly rewritten: number;
76
+ /** Registry prefixes that were rewritten. */
77
+ readonly registries: readonly string[];
78
+ }
79
+
80
+ /** URL origin plus path prefix of a registry, without a trailing slash. */
81
+ function registryPrefix(url: string): string | undefined {
82
+ try {
83
+ const parsed = new URL(url);
84
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Registry URLs to rewrite for `cwd`.
92
+ *
93
+ * `auto` adds the active npm registry only when it is not public npm, so a
94
+ * machine on npmjs leaves the lockfile untouched.
95
+ */
96
+ export function bunLockfileRegistries(
97
+ cwd: string,
98
+ options: Pick<BunLockfileOptions, "mode" | "registries">,
99
+ ): string[] {
100
+ if (options.mode === "off") return [];
101
+ const detected = npmRegistry(cwd, { overrideOnly: true })?.href;
102
+ return [...(detected ? [detected] : []), ...options.registries];
103
+ }
104
+
105
+ /** Public npm tarball URL for one package version. */
106
+ function npmjsTarball(name: string, version: string): string {
107
+ const base = name.slice(name.lastIndexOf("/") + 1);
108
+ return `${PUBLIC_NPM_REGISTRY}/${name}/-/${base}-${version}.tgz`;
109
+ }
110
+
111
+ /** Rewrite tarball URLs under any of `registries`; leave every other URL as is. */
112
+ export function normalizeBunLockfile(
113
+ content: string,
114
+ registries: readonly string[],
115
+ replacement: z.output<typeof BunLockfileReplacementSchema> = "empty",
116
+ ): NormalizedBunLockfile {
117
+ const prefixes = [...new Set(registries.flatMap((registry) => registryPrefix(registry) ?? []))];
118
+ if (prefixes.length === 0) return { content, rewritten: 0, registries: prefixes };
119
+ let rewritten = 0;
120
+ const normalized = content.replace(
121
+ TARBALL_ENTRY,
122
+ (entry, head: string, name: string, version: string, separator: string, url: string) => {
123
+ if (!prefixes.some((prefix) => url.startsWith(`${prefix}/`))) return entry;
124
+ rewritten += 1;
125
+ const tarball = replacement === "npmjs" ? npmjsTarball(name, version) : "";
126
+ return `${head}${name}@${version}${separator}"${tarball}"`;
127
+ },
128
+ );
129
+ return { content: normalized, rewritten, registries: prefixes };
130
+ }
131
+
132
+ /** Outcome of {@link normalizeBunLockfileAt}. */
133
+ export interface BunLockfileNormalization extends NormalizedBunLockfile {
134
+ /** Absolute lockfile path. */
135
+ readonly path: string;
136
+ /** Whether the lockfile exists. */
137
+ readonly exists: boolean;
138
+ /** Whether the file content differs from its normalized form. */
139
+ readonly changed: boolean;
140
+ }
141
+
142
+ /** Normalize `bun.lock` in `root`, writing it unless `check` is set. */
143
+ export function normalizeBunLockfileAt(
144
+ root: string,
145
+ input: BunLockfileOptionsInput = {},
146
+ ): BunLockfileNormalization {
147
+ const options = BunLockfileOptionsSchema.parse(input);
148
+ const path = join(root, BUN_LOCKFILE);
149
+ if (!existsSync(path)) {
150
+ return { path, exists: false, changed: false, content: "", rewritten: 0, registries: [] };
151
+ }
152
+ const original = readFileSync(path, "utf8");
153
+ const result = normalizeBunLockfile(
154
+ original,
155
+ bunLockfileRegistries(root, options),
156
+ options.replacement,
157
+ );
158
+ const changed = result.content !== original;
159
+ if (changed && !options.check) writeFileSync(path, result.content);
160
+ return { ...result, path, exists: true, changed };
161
+ }
package/src/project-js.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  import { folderBarrelFile, generateBarrels } from "./barrels.ts";
33
33
 
34
34
  import { BUN_APP_OVERRIDES, RootBunfigFile } from "./bun-app.ts";
35
+ import { BUN_LOCKFILE } from "./bun-lockfile.ts";
35
36
  import { BUN_VERSION, BUN_VERSION_CHECK_SCRIPT, MINIMUM_BUN_VERSION } from "./bun-workflow.ts";
36
37
  import { codegenModulePaths, generateCodegen } from "./codegen.ts";
37
38
  import { DBXToolsConfig, type DBXToolsConfigOptions } from "./dbx-tools-config.ts";
@@ -527,6 +528,33 @@ const DEV_DEPS_ROOT: string[] = [
527
528
  "@types/node@^24.6.0",
528
529
  ];
529
530
 
531
+ /** Root task that clears registry tarball URLs from `bun.lock`. */
532
+ export const BUN_LOCKFILE_TASK = "bun-lockfile";
533
+
534
+ /**
535
+ * Commit `bun.lock` without leaking the installing machine's registry.
536
+ *
537
+ * Bun writes each resolved tarball URL into the lockfile. The generated
538
+ * `bun-lockfile` task detects the active npm registry (resolved like a package
539
+ * manager does, never hard-coded) and, when it is not public npm, rewrites every
540
+ * tarball URL under it; all other URLs stay as they are. It runs after every
541
+ * root install and as the root `postinstall` script, so `bun add`, `bun remove`,
542
+ * and `bun update` normalize too. CI installs with `--frozen-lockfile`.
543
+ */
544
+ function configureRootLockfile(project: DBXToolsJavaScriptProject): void {
545
+ const normalize = taskCommand(`${BUN_LOCKFILE_TASK}.ts`);
546
+ const task = project.addTask(BUN_LOCKFILE_TASK, {
547
+ description: "Rewrite private npm registry tarball URLs in bun.lock",
548
+ execArgs: normalize,
549
+ receiveArgs: true,
550
+ });
551
+ project.package.installTask.reset("bun install");
552
+ project.package.installTask.spawn(task);
553
+ project.package.installCiTask.reset(project.package.installCommand);
554
+ project.package.setScript("postinstall", normalize.join(" "));
555
+ project.annotateGenerated(`/${BUN_LOCKFILE}`);
556
+ }
557
+
530
558
  function configureRootBunRuntime(project: DBXToolsJavaScriptProject): void {
531
559
  new TextFile(project, BUN_VERSION_CHECK_SCRIPT, {
532
560
  lines: dedent(// ============================================================================
@@ -1330,9 +1358,7 @@ function initProject(
1330
1358
  if (selfDep) project.addDevDeps(selfDep);
1331
1359
  project.addDevDeps(...DEV_DEPS_ROOT);
1332
1360
  configureRootPackage(project);
1333
- // Workspaces generated by this engine intentionally do not track local-registry
1334
- // lockfiles, so the CI-named install task must resolve from manifests as well.
1335
- project.package.installCiTask.reset("bun install");
1361
+ configureRootLockfile(project);
1336
1362
  configureRootBunRuntime(project);
1337
1363
  const roots =
1338
1364
  options.packageRoots ??
@@ -1367,6 +1393,9 @@ function initProject(
1367
1393
  project.gitignore.addPatterns(
1368
1394
  ...[...ignore.ignorePatterns({ test: false, dot: false, lock: true })],
1369
1395
  );
1396
+ // The root Bun text lockfile is committed in registry-neutral form; see
1397
+ // configureRootLockfile. Other lockfiles stay ignored.
1398
+ project.gitignore.include(`/${BUN_LOCKFILE}`);
1370
1399
  // The generic compiled-output pattern `**/lib/**` must not hide authored
1371
1400
  // modules beneath a package's `src/lib/`.
1372
1401
  project.gitignore.include("**/src/lib/**");
package/src/release.ts CHANGED
@@ -276,7 +276,7 @@ function releaseBuildJob(
276
276
  fetchDepth: 0,
277
277
  },
278
278
  }),
279
- ...project.renderWorkflowSetup({ mutable: true }),
279
+ ...project.renderWorkflowSetup({ mutable: false }),
280
280
  {
281
281
  name: "Verify release context",
282
282
  id: "release",
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env -S bun
2
+ /**
3
+ * Keep `bun.lock` free of private npm registry URLs.
4
+ *
5
+ * Detects the active npm registry and, when it is not public npm, rewrites
6
+ * every tarball URL under it. Options also read `PROJEN_BUN_LOCKFILE_*`
7
+ * environment variables and `.env`.
8
+ */
9
+ import { relative } from "node:path";
10
+ import { log } from "@dbx-tools/shared-core";
11
+ import { runTaskMain, taskCommand, taskOptions, taskRoot } from "./cli.ts";
12
+ import { TaskRootOptionSchema } from "./options.ts";
13
+ import { BunLockfileOptionsSchema, normalizeBunLockfileAt } from "../src/bun-lockfile.ts";
14
+
15
+ const logger = log.logger("projen:bun-lockfile");
16
+
17
+ const TaskSchema = BunLockfileOptionsSchema.extend({ root: TaskRootOptionSchema });
18
+
19
+ export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
20
+ const { root, ...options } = await taskOptions(
21
+ taskCommand(import.meta.url, "Keep bun.lock free of private npm registry URLs", TaskSchema),
22
+ TaskSchema,
23
+ args,
24
+ );
25
+ const result = normalizeBunLockfileAt(taskRoot(root), options);
26
+ if (!result.exists || !result.changed) return;
27
+ const file = relative(process.cwd(), result.path) || result.path;
28
+ if (options.check) {
29
+ throw new Error(
30
+ `${file} contains ${result.rewritten} private registry tarball URLs; run \`bun run bun-lockfile\``,
31
+ );
32
+ }
33
+ logger.info(`rewrote ${result.rewritten} private registry tarball URLs`, { file });
34
+ }
35
+
36
+ await runTaskMain(import.meta, main);
package/tasks/publish.ts CHANGED
@@ -50,6 +50,7 @@ import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:pa
50
50
  import { log, mapConcurrent, sequence } from "@dbx-tools/shared-core";
51
51
  import { z } from "zod";
52
52
  import { runTaskMain, taskCommand, taskOptions, taskPositionals, taskRoot } from "./cli.ts";
53
+ import { publishNpmArchives } from "./publish-npm.ts";
53
54
  import {
54
55
  TaskConcurrencyOptionSchema,
55
56
  TaskDirectoriesOptionSchema,
@@ -59,7 +60,7 @@ import {
59
60
  } from "./options.ts";
60
61
  import { runTaskCommand } from "../src/_task-command.ts";
61
62
  import { applyPublishConfig, packNpmPackage } from "../src/npm-packaging.ts";
62
- import { publishNpmArchive, readNpmArchiveIdentity } from "../src/npm-release.ts";
63
+ import { readNpmArchiveIdentity } from "../src/npm-release.ts";
63
64
  import { recordedPackages } from "../src/packages.ts";
64
65
 
65
66
  const logger = log.logger("projen:publish");
@@ -212,58 +213,52 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
212
213
  }
213
214
  }
214
215
 
215
- logger.info(
216
- `${output ? "packing" : dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
217
- );
218
- if (output) {
219
- rmSync(output, { recursive: true, force: true });
220
- mkdirSync(output, { recursive: true });
221
- }
222
- await mapConcurrent(
223
- publishable,
224
- async ({ dir, name, version: packageVersion, access }) => {
225
- const packed = mkdtempSync(join(tmpdir(), "projen-npm-release-"));
226
- try {
227
- const archive = packNpmPackage(dir, packed, path, applyPublishConfig);
228
- const local = readNpmArchiveIdentity(archive);
229
- if (local.name !== name || local.version !== packageVersion) {
230
- throw new Error(
231
- `Packed npm identity ${local.name}@${local.version} does not match ${name}@${packageVersion}`,
232
- );
233
- }
234
- if (local.access !== access) {
235
- throw new Error(
236
- `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
237
- );
238
- }
239
- if (output) {
240
- const destination = join(output, archive.split(/[\\/]/).at(-1)!);
216
+ logger.info(`packing ${publishable.length} packages with concurrency ${concurrency}`);
217
+ const archiveDirectory = output ?? mkdtempSync(join(tmpdir(), "projen-npm-publish-"));
218
+ if (output) rmSync(archiveDirectory, { recursive: true, force: true });
219
+ mkdirSync(archiveDirectory, { recursive: true });
220
+ try {
221
+ await mapConcurrent(
222
+ publishable,
223
+ async ({ dir, name, version: packageVersion, access }) => {
224
+ const packed = mkdtempSync(join(tmpdir(), "projen-npm-release-"));
225
+ try {
226
+ const archive = packNpmPackage(dir, packed, path, applyPublishConfig);
227
+ const local = readNpmArchiveIdentity(archive);
228
+ if (local.name !== name || local.version !== packageVersion) {
229
+ throw new Error(
230
+ `Packed npm identity ${local.name}@${local.version} does not match ${name}@${packageVersion}`,
231
+ );
232
+ }
233
+ if (local.access !== access) {
234
+ throw new Error(
235
+ `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
236
+ );
237
+ }
238
+ const destination = join(archiveDirectory, archive.split(/[\\/]/).at(-1)!);
241
239
  if (existsSync(destination)) {
242
240
  throw new Error(`Duplicate npm archive name: ${destination}`);
243
241
  }
244
242
  copyFileSync(archive, destination);
245
243
  logger.info(`packed ${name} @ ${packageVersion}`);
246
- return;
244
+ } finally {
245
+ rmSync(packed, { recursive: true, force: true });
247
246
  }
248
- const outcome = await publishNpmArchive({
249
- archive,
250
- identity: local,
251
- cwd: dir,
252
- dryRun,
253
- env: { ...process.env, PATH: path },
254
- registry,
255
- });
256
- if (outcome === "skipped") {
257
- logger.info(`skip published ${name} @ ${packageVersion}`);
258
- return;
259
- }
260
- logger.info(`${dryRun ? "dry-run published" : "published"} ${name} @ ${packageVersion}`);
261
- } finally {
262
- rmSync(packed, { recursive: true, force: true });
263
- }
264
- },
265
- { concurrency, errorMode: "settle" },
266
- );
247
+ },
248
+ { concurrency, errorMode: "settle" },
249
+ );
250
+ if (!output) {
251
+ await publishNpmArchives({
252
+ concurrency,
253
+ directory: archiveDirectory,
254
+ dryRun,
255
+ registry,
256
+ version,
257
+ });
258
+ }
259
+ } finally {
260
+ if (!output) rmSync(archiveDirectory, { recursive: true, force: true });
261
+ }
267
262
  logger.success(
268
263
  `${output || dryRun ? "packed" : "published"} ${publishable.length} packages${output ? ` to ${output}` : ""}`,
269
264
  );
@@ -16,7 +16,17 @@ import { captureTaskCommand, tryTaskCommand } from "../src/_task-command.ts";
16
16
  const logger = log.logger("projen:release-notes");
17
17
 
18
18
  const GENIE_PROMPT =
19
- "Compare HEAD and the working tree against the latest v* tag. Produce concise user-facing release notes. Ignore generated-file churn and test-only changes. Include breaking changes, fixes, and validation results. The configured release validation phase has already completed, so do not run tests or other validation commands and do not invent validation counts. Do not modify files.";
19
+ "Produce concise user-facing release notes. Ignore generated-file churn and test-only changes. Include breaking changes, fixes, and validation results. The configured release validation phase has already completed, so do not run tests or other validation commands and do not invent validation counts. Do not modify files.";
20
+
21
+ /** Inputs that define the release-note comparison and optional caller guidance. */
22
+ export interface ReleaseNotesPromptOptions {
23
+ /** Release tag whose GitHub Actions release workflow most recently succeeded. */
24
+ readonly successfulTag?: string;
25
+ /** Extra agent instructions appended after the standard release-note requirements. */
26
+ readonly instructions?: string;
27
+ /** Release tag prefix used when no successful GitHub baseline exists. */
28
+ readonly prefix: string;
29
+ }
20
30
 
21
31
  /** Inputs for one notes write. */
22
32
  export interface WriteReleaseNotesOptions {
@@ -28,15 +38,21 @@ export interface WriteReleaseNotesOptions {
28
38
  readonly instructions?: string;
29
39
  readonly prefix: string;
30
40
  readonly root: string;
41
+ /** Release tag whose GitHub Actions release workflow most recently succeeded. */
42
+ readonly successfulTag?: string;
31
43
  readonly version: string;
32
44
  /** Override Genie invocation; return false to force the git-log fallback. */
33
45
  readonly runGenie?: (args: readonly string[]) => boolean;
34
46
  }
35
47
 
36
- /** Compose the stable release-note prompt with optional caller guidance. */
37
- export function releaseNotesPrompt(instructions?: string): string {
38
- const additional = instructions?.trim();
39
- return additional ? `${GENIE_PROMPT}\n\nAdditional instructions:\n${additional}` : GENIE_PROMPT;
48
+ /** Compose the stable release-note prompt with the deployed baseline and caller guidance. */
49
+ export function releaseNotesPrompt(options: ReleaseNotesPromptOptions): string {
50
+ const baseline = options.successfulTag
51
+ ? `The latest GitHub release workflow that completed successfully is ${options.successfulTag}. Compare ${options.successfulTag}..HEAD plus the working tree. Treat any newer ${options.prefix}* tags and their release-note files as failed release attempts: combine their user-facing changes into this release, verify them against the cumulative code delta, and do not shorten the comparison to those failed tags. Do not reduce the notes to retry-only deployment or workflow fixes such as \"changed workflow X\" when unreleased product changes are part of the same cumulative delta.`
52
+ : `No successful GitHub release workflow baseline was found. Do not assume the latest ${options.prefix}* tag deployed successfully. Inspect the available release-note files, tags, git history, HEAD, and the working tree, then produce cumulative notes for the user-facing changes not known to have shipped successfully. Do not reduce the notes to retry-only deployment or workflow fixes when unreleased product changes are present.`;
53
+ const prompt = `${baseline}\n\n${GENIE_PROMPT}`;
54
+ const additional = options.instructions?.trim();
55
+ return additional ? `${prompt}\n\nAdditional instructions:\n${additional}` : prompt;
40
56
  }
41
57
 
42
58
  /** Path of the notes file for one workspace version. */
@@ -92,7 +108,11 @@ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
92
108
  "--ephemeral",
93
109
  "-o",
94
110
  relative,
95
- releaseNotesPrompt(options.instructions),
111
+ releaseNotesPrompt({
112
+ instructions: options.instructions,
113
+ prefix,
114
+ successfulTag: options.successfulTag,
115
+ }),
96
116
  ] as const;
97
117
  const ran =
98
118
  options.runGenie?.([...genieArgs]) ??
@@ -101,20 +121,14 @@ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
101
121
  logger.info("wrote Genie release notes", { path: relative });
102
122
  return destination;
103
123
  }
104
- const previousTag = captureTaskCommand(root, "git", [
105
- "describe",
106
- "--tags",
107
- "--abbrev=0",
108
- `--match=${prefix}*`,
109
- ]);
110
- const logArgs = previousTag
111
- ? ["log", "--no-merges", "--pretty=format:%s", `${previousTag}..HEAD`]
124
+ const logArgs = options.successfulTag
125
+ ? ["log", "--no-merges", "--pretty=format:%s", `${options.successfulTag}..HEAD`]
112
126
  : ["log", "--no-merges", "--pretty=format:%s", "-15"];
113
127
  const commits = captureTaskCommand(root, "git", logArgs)
114
128
  .split("\n")
115
129
  .map((line) => line.trim())
116
130
  .filter(Boolean);
117
- writeFileSync(destination, fallbackReleaseNotes(version, commits, previousTag || undefined));
131
+ writeFileSync(destination, fallbackReleaseNotes(version, commits, options.successfulTag));
118
132
  logger.warn("Genie release notes unavailable; wrote git-log summary", { path: relative });
119
133
  return destination;
120
134
  }
package/tasks/release.ts CHANGED
@@ -81,6 +81,18 @@ export type ReleaseGithubActionsWatcher = (
81
81
  options: WatchReleaseGithubActionsOptions,
82
82
  ) => Promise<void>;
83
83
 
84
+ /** Inputs required to find the newest successfully deployed release tag. */
85
+ export interface ResolveSuccessfulReleaseTagOptions {
86
+ readonly account: ProjectGhAccount;
87
+ readonly prefix: string;
88
+ readonly root: string;
89
+ }
90
+
91
+ /** Successful release lookup boundary replaceable by release tests. */
92
+ export type SuccessfulReleaseTagResolver = (
93
+ options: ResolveSuccessfulReleaseTagOptions,
94
+ ) => string | undefined;
95
+
84
96
  function githubRepository(account: ProjectGhAccount): string {
85
97
  return account.host === "github.com"
86
98
  ? account.remote.repository
@@ -146,6 +158,61 @@ function reportGithubProgress(
146
158
  }
147
159
  }
148
160
 
161
+ /** Find the newest release tag whose GitHub Actions release workflow succeeded. */
162
+ export function resolveSuccessfulReleaseTag(
163
+ options: ResolveSuccessfulReleaseTagOptions,
164
+ ): string | undefined {
165
+ const { account, prefix, root } = options;
166
+ const output = captureTaskCommand(
167
+ root,
168
+ "gh",
169
+ [
170
+ "run",
171
+ "list",
172
+ "--repo",
173
+ githubRepository(account),
174
+ "--workflow",
175
+ RELEASE_WORKFLOW_FILE,
176
+ "--event",
177
+ "push",
178
+ "--status",
179
+ "success",
180
+ "--limit",
181
+ "100",
182
+ "--json",
183
+ "databaseId,headBranch,headSha,status,url",
184
+ ],
185
+ { env: account.env, stderr: "inherit" },
186
+ );
187
+ const parsed = z.array(GithubReleaseRunSchema).safeParse(parse(output, []));
188
+ if (!parsed.success) {
189
+ logger.warn("GitHub returned invalid successful release workflow metadata", {
190
+ error: parsed.error,
191
+ repository: account.remote.repository,
192
+ });
193
+ return undefined;
194
+ }
195
+ for (const run of parsed.data) {
196
+ if (!run.headBranch.startsWith(prefix)) continue;
197
+ const tagHead = gitHead(root, `refs/tags/${run.headBranch}^{}`);
198
+ if (tagHead !== run.headSha) continue;
199
+ if (!taskCommandSucceeds(root, "git", ["merge-base", "--is-ancestor", tagHead, "HEAD"])) {
200
+ continue;
201
+ }
202
+ logger.info("using successful GitHub release as the release-note baseline", {
203
+ repository: account.remote.repository,
204
+ runId: run.databaseId,
205
+ tag: run.headBranch,
206
+ url: run.url,
207
+ });
208
+ return run.headBranch;
209
+ }
210
+ logger.info("no successful GitHub release baseline was found", {
211
+ repository: account.remote.repository,
212
+ });
213
+ return undefined;
214
+ }
215
+
149
216
  /** Whether this release selection has remote work worth watching. */
150
217
  export function shouldWatchReleaseGithubActions(
151
218
  options: Pick<ReleaseSelectionOptions, "publish"> & { readonly watchGithubActions?: boolean },
@@ -247,7 +314,8 @@ export async function watchReleaseGithubActions(
247
314
  root,
248
315
  "gh",
249
316
  ["run", "view", String(run.databaseId), "--repo", repository, "--log-failed"],
250
- { env: account.env },
317
+ // gh pipes TTY log output through its pager; release logs must stream inline.
318
+ { env: { ...account.env, GH_PAGER: "cat" } },
251
319
  );
252
320
  }
253
321
  } catch (error) {
@@ -482,11 +550,21 @@ export async function runRelease(
482
550
  readonly watchGithubActions?: boolean;
483
551
  readonly githubActionsWatcher?: ReleaseGithubActionsWatcher;
484
552
  readonly resolveGithubAccount?: typeof resolveProjectGhAccount;
553
+ readonly resolveSuccessfulReleaseTag?: SuccessfulReleaseTagResolver;
485
554
  },
486
555
  ): Promise<string> {
487
556
  const { branch, prefix, remote, root } = options;
488
557
  const selection = releaseStepSelection(options);
489
558
  const install = options.install ?? "auto";
559
+ let githubAccount: ProjectGhAccount | undefined;
560
+ let githubAccountResolved = false;
561
+ const getGithubAccount = (): ProjectGhAccount | undefined => {
562
+ if (!githubAccountResolved) {
563
+ githubAccount = (options.resolveGithubAccount ?? resolveProjectGhAccount)(root, { remote });
564
+ githubAccountResolved = true;
565
+ }
566
+ return githubAccount;
567
+ };
490
568
 
491
569
  if (options.releaseNotesText !== undefined && options.releaseNotesFile !== undefined) {
492
570
  throw new Error("--release-notes-text and --release-notes-file are mutually exclusive");
@@ -539,9 +617,25 @@ export async function runRelease(
539
617
  runTaskCommand(root, "bun", ["run", task]);
540
618
  }
541
619
  if ((options.releaseNotes ?? true) && (options.bump ?? true)) {
620
+ let successfulTag: string | undefined;
621
+ if (options.releaseNotesText === undefined && options.releaseNotesFile === undefined) {
622
+ const account = getGithubAccount();
623
+ if (account) {
624
+ successfulTag = (options.resolveSuccessfulReleaseTag ?? resolveSuccessfulReleaseTag)({
625
+ account,
626
+ prefix,
627
+ root,
628
+ });
629
+ } else {
630
+ logger.info("successful GitHub release baseline is unavailable: no authenticated account", {
631
+ remote,
632
+ });
633
+ }
634
+ }
542
635
  (options.writeReleaseNotes ?? writeReleaseNotes)({
543
636
  prefix,
544
637
  root,
638
+ successfulTag,
545
639
  version,
546
640
  content: options.releaseNotesText,
547
641
  file: options.releaseNotesFile,
@@ -619,9 +713,7 @@ export async function runRelease(
619
713
  }
620
714
 
621
715
  if (shouldWatchReleaseGithubActions(options, selection)) {
622
- const account = (options.resolveGithubAccount ?? resolveProjectGhAccount)(root, {
623
- remote,
624
- });
716
+ const account = getGithubAccount();
625
717
  if (account) {
626
718
  await (options.githubActionsWatcher ?? watchReleaseGithubActions)({
627
719
  account,