@dbx-tools/projen 0.6.15 → 0.6.27

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
@@ -95,7 +95,12 @@ barrels.generateBarrels();
95
95
 
96
96
  `generateCodegen()` reads `package.json` `codegen.inputs` and writes generated
97
97
  schema modules. `generateBarrels()` writes package-root `index.ts` barrels with
98
- module namespaces and flat unique type exports.
98
+ module namespaces and flat unique type exports, returning the number that
99
+ actually changed. A barrel whose export surface is unchanged is left untouched,
100
+ read-only bit included, so concurrent writers never collide over it. Every
101
+ package is attempted even if one fails; the failures are re-thrown together as an
102
+ `AggregateError` naming each package, rather than the first one abandoning the
103
+ rest of the sweep.
99
104
 
100
105
  ## Generate OpenAPI Clients
101
106
 
package/package.json CHANGED
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "main": "index.ts",
34
34
  "license": "Apache-2.0",
35
- "version": "0.6.15",
35
+ "version": "0.6.27",
36
36
  "types": "index.ts",
37
37
  "type": "module",
38
38
  "exports": {
@@ -51,6 +51,7 @@
51
51
  "clobber": "projen clobber",
52
52
  "compile": "projen compile",
53
53
  "default": "projen default",
54
+ "demo": "projen demo",
54
55
  "eject": "projen eject",
55
56
  "package": "projen package",
56
57
  "post-compile": "projen post-compile",
package/src/barrels.ts CHANGED
@@ -36,19 +36,13 @@
36
36
  * The result gets a do-not-edit header + read-only bit (see `./generated`).
37
37
  */
38
38
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
39
- import { join } from "node:path";
39
+ import { join, relative } from "node:path";
40
40
  import { find } from "@dbx-tools/path";
41
41
  import { string } from "@dbx-tools/shared-core";
42
42
  import isIdentifier from "is-identifier";
43
- import {
44
- header,
45
- makeReadonly,
46
- makeWritable,
47
- stampGenerated,
48
- type HeaderOpts,
49
- } from "./generated.ts";
43
+ import { header, makeReadonly, makeWritable, type HeaderOpts } from "./generated.ts";
50
44
  import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports.ts";
51
- import { isModuleFile, toPosix, recordedPackages } from "./packages.ts";
45
+ import { isModuleFile, toPosix, recordedPackages, repoRoot } from "./packages.ts";
52
46
 
53
47
  /**
54
48
  * A `src`-relative posix path excluded from the root barrel:
@@ -292,9 +286,6 @@ function generateForPackage(pkgDir: string): number {
292
286
  return 0;
293
287
  }
294
288
 
295
- // Unlock the read-only barrel so the rewrite below can replace it.
296
- makeWritable(rootBarrel);
297
-
298
289
  // `./src/<path>` with the module's REAL extension, namespaced by its path
299
290
  // segments (camelCase; invalid identifiers suffixed with `Module`). The
300
291
  // extension is written because `tsc` rewrites it on emit
@@ -315,30 +306,90 @@ function generateForPackage(pkgDir: string): number {
315
306
  content = mergeCustomExports(content, pkgDir);
316
307
 
317
308
  // The barrel only *changes* when its set of exporting modules does. If the
318
- // stamped result matches what's already on disk, restore the read-only bit we
319
- // cleared above and report no change (0) - this keeps the watcher quiet on
320
- // ordinary in-file edits (which leave the export * as … list identical).
309
+ // stamped result matches what's already on disk, leave the file - and its
310
+ // read-only bit - completely untouched and report no change (0). This keeps the
311
+ // watcher quiet on ordinary in-file edits (which leave the export * as … list
312
+ // identical), and it is also what keeps a no-op cycle off the read-only bit
313
+ // entirely: see {@link writeBarrel} for why unlocking a barrel we are not about
314
+ // to rewrite is what produced spurious EACCES failures.
321
315
  content = `${content.replace(/\n+$/, "")}\n`;
322
316
  const next = `${header(BARREL_HEADER)}\n${content}`;
323
- if (before === next) {
324
- makeReadonly(rootBarrel);
325
- return 0;
326
- }
317
+ if (before === next) return 0;
327
318
 
328
- writeFileSync(rootBarrel, content);
329
- stampGenerated(rootBarrel, BARREL_HEADER);
319
+ // Written whole (header included) rather than via `stampGenerated`, which would
320
+ // re-read and rewrite the file to prepend the same header - a second write, and
321
+ // therefore a second window in which the read-only bit can come back. `next` is
322
+ // byte-for-byte what the comparison above accepted, so the two cannot drift.
323
+ writeBarrel(rootBarrel, next);
324
+ makeReadonly(rootBarrel);
330
325
  return 1;
331
326
  }
332
327
 
328
+ /**
329
+ * Attempts at unlocking and writing a barrel before giving up. A sibling process
330
+ * can restore the read-only bit between the two.
331
+ */
332
+ const WRITE_ATTEMPTS = 3;
333
+
334
+ /**
335
+ * Unlock a read-only barrel and replace it, retrying on `EACCES`.
336
+ *
337
+ * The unlock cannot be hoisted to the top of {@link generateForPackage}: several
338
+ * processes write barrels concurrently under `sync --watch` (the barrels watcher,
339
+ * and the projenrc watcher's post-synth `generateBarrels()` sweep), so any gap
340
+ * between `makeWritable` and the write is a window in which another process's
341
+ * `makeReadonly` lands and this write fails with
342
+ * `EACCES: permission denied, open '<pkg>/index.ts'`. The gap used to span all of
343
+ * the oxc parsing done for export hoisting, which made it wide enough to hit
344
+ * routinely. Keeping the unlock adjacent to the write shrinks it to nothing much,
345
+ * and a retry absorbs what is left.
346
+ *
347
+ * Do NOT "simplify" this back to a single unlock-then-write: the failure is
348
+ * timing-dependent, so it looks fine until a full-repo sweep runs against a
349
+ * concurrent one.
350
+ */
351
+ function writeBarrel(file: string, content: string): void {
352
+ for (let attempt = 1; ; attempt++) {
353
+ makeWritable(file);
354
+ try {
355
+ writeFileSync(file, content);
356
+ return;
357
+ } catch (err) {
358
+ const code = (err as NodeJS.ErrnoException).code;
359
+ if (code !== "EACCES" || attempt >= WRITE_ATTEMPTS) throw err;
360
+ }
361
+ }
362
+ }
363
+
333
364
  /**
334
365
  * Rebuild barrels for the given package dirs (default: every package recorded in
335
366
  * `pnpm-workspace.yaml` - the source of truth, read via `recordedPackages()`).
336
367
  * Returns the number of barrels whose contents actually changed (an unchanged
337
368
  * export surface is a no-op), so callers can stay quiet when nothing moved.
369
+ *
370
+ * Every package is attempted even if an earlier one fails, and the failures are
371
+ * re-thrown together as an `AggregateError` naming each package. Letting the first
372
+ * failure propagate instead abandoned every package after it in the iteration
373
+ * order, so one unwritable barrel silently left the rest of the repo stale with
374
+ * nothing in the log to say which packages had been skipped.
338
375
  */
339
376
  export function generateBarrels(opts: { dirs?: string[] } = {}): number {
340
377
  const dirs = opts.dirs ?? recordedPackages().map((p) => p.dir);
341
378
  let total = 0;
342
- for (const dir of dirs) total += generateForPackage(dir);
379
+ const failures: { dir: string; err: unknown }[] = [];
380
+ for (const dir of dirs) {
381
+ try {
382
+ total += generateForPackage(dir);
383
+ } catch (err) {
384
+ failures.push({ dir, err });
385
+ }
386
+ }
387
+ if (failures.length) {
388
+ const names = failures.map((f) => relative(repoRoot, f.dir) || f.dir);
389
+ throw new AggregateError(
390
+ failures.map((f) => f.err),
391
+ `${string.pluralize(failures.length, "barrel")} failed: ${names.join(", ")}`,
392
+ );
393
+ }
343
394
  return total;
344
395
  }
package/src/release.ts CHANGED
@@ -6,9 +6,38 @@
6
6
  */
7
7
  import { Component } from "projen";
8
8
  import { GithubWorkflow } from "projen/lib/github";
9
- import { JobPermission } from "projen/lib/github/workflows-model";
9
+ import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
10
10
  import { applyTasks, taskScript, type DBXToolsNodeProject } from "./project.ts";
11
11
 
12
+ const NODE_VERSION = "lts/*";
13
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
14
+ const PNPM_VERSION = "10.33.0";
15
+
16
+ interface PublishWorkflow {
17
+ readonly name: string;
18
+ readonly tagPrefix: string;
19
+ readonly steps: readonly JobStep[];
20
+ readonly workingDirectory?: string;
21
+ }
22
+
23
+ /** Shared checkout and toolchain setup for every npm publish workflow. */
24
+ function publishSetupSteps(): JobStep[] {
25
+ return [
26
+ { name: "Checkout", uses: "actions/checkout@v6", with: { "fetch-depth": 0 } },
27
+ { name: "Setup pnpm", uses: "pnpm/action-setup@v5", with: { version: PNPM_VERSION } },
28
+ {
29
+ name: "Setup Node.js",
30
+ uses: "actions/setup-node@v6",
31
+ // setup-node writes the temporary npmrc that maps NODE_AUTH_TOKEN onto
32
+ // npmjs. Omitting this leaves the secret in the environment but gives npm
33
+ // no registry-scoped auth entry, and every publish fails with ENEEDAUTH.
34
+ with: { "node-version": NODE_VERSION, "registry-url": NPM_REGISTRY_URL },
35
+ },
36
+ // The lockfile is intentionally untracked and may be absent or stale in CI.
37
+ { name: "Install", run: "pnpm install --no-frozen-lockfile" },
38
+ ];
39
+ }
40
+
12
41
  /**
13
42
  * A standalone project that lives in a repo SUBDIRECTORY but is NOT a member of
14
43
  * this pnpm workspace (e.g. the `@dbx-tools/projen` engine in `projen/`), yet
@@ -115,43 +144,44 @@ export class DBXToolsRelease extends Component {
115
144
  }
116
145
  }
117
146
 
118
- /**
119
- * Emit the `release` GitHub workflow: push `<prefix>1.2.3` and every
120
- * publishable package is published to npm at 1.2.3. Setting the
121
- * version on every package first makes the pushed tag the published version
122
- * (no bump math).
123
- */
124
- private authorReleaseWorkflow(project: DBXToolsNodeProject): void {
125
- const workflow = new GithubWorkflow(project.github!, "release", {
147
+ /** Author the common tag trigger, concurrency policy, permissions, and publish job. */
148
+ private authorPublishWorkflow(
149
+ project: DBXToolsNodeProject,
150
+ { name, tagPrefix, steps, workingDirectory }: PublishWorkflow,
151
+ ): void {
152
+ const workflow = new GithubWorkflow(project.github!, name, {
126
153
  // Serialize publishes so two tags landing together cannot race to the
127
154
  // registry, but never cancel a run already in flight: a half-published
128
155
  // release is worse than a queued one.
129
156
  limitConcurrency: true,
130
- concurrencyOptions: { group: "release", cancelInProgress: false },
157
+ concurrencyOptions: { group: name, cancelInProgress: false },
131
158
  });
132
159
  // Read-only floor for any job that does not declare its own permissions;
133
160
  // the publish job below overrides it with the `id-token` it needs.
134
161
  workflow.file?.addOverride("permissions", { contents: "read" });
135
- workflow.on({ push: { tags: [`${this.tagPrefix}*`] } });
162
+ workflow.on({ push: { tags: [`${tagPrefix}*`] } });
136
163
  workflow.addJob("publish", {
137
164
  runsOn: ["ubuntu-latest"],
138
165
  // `id-token: write` lets npm mint the OIDC token for provenance attestation.
139
166
  permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
140
167
  timeoutMinutes: 30,
141
168
  env: { CI: "true" },
169
+ steps: [...publishSetupSteps(), ...steps],
170
+ ...(workingDirectory ? { defaults: { run: { workingDirectory } } } : {}),
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Emit the `release` GitHub workflow: push `<prefix>1.2.3` and every
176
+ * publishable package is published to npm at 1.2.3. Setting the
177
+ * version on every package first makes the pushed tag the published version
178
+ * (no bump math).
179
+ */
180
+ private authorReleaseWorkflow(project: DBXToolsNodeProject): void {
181
+ this.authorPublishWorkflow(project, {
182
+ name: "release",
183
+ tagPrefix: this.tagPrefix,
142
184
  steps: [
143
- { name: "Checkout", uses: "actions/checkout@v6", with: { "fetch-depth": 0 } },
144
- { name: "Setup pnpm", uses: "pnpm/action-setup@v5", with: { version: "10.33.0" } },
145
- {
146
- name: "Setup Node.js",
147
- uses: "actions/setup-node@v6",
148
- with: { "node-version": "lts/*", "registry-url": "https://registry.npmjs.org" },
149
- },
150
- // NOT frozen. The lockfile is gitignored by policy (it can carry a
151
- // private-registry fingerprint), so CI often has none or a stale one -
152
- // and `--frozen-lockfile` turns that into a hard release failure rather
153
- // than resolving. A blocked publish is worse here than a resolved one.
154
- { name: "Install", run: "pnpm install --no-frozen-lockfile" },
155
185
  // The pushed tag is the version: `<prefix>1.2.3` -> `1.2.3`. Set it on
156
186
  // every package (manifests are projen-readonly, so unlock them first).
157
187
  {
@@ -189,32 +219,11 @@ export class DBXToolsRelease extends Component {
189
219
  project: DBXToolsNodeProject,
190
220
  { name, directory, tagPrefix }: StandaloneRelease,
191
221
  ): void {
192
- const workflow = new GithubWorkflow(project.github!, name, {
193
- limitConcurrency: true,
194
- concurrencyOptions: { group: name, cancelInProgress: false },
195
- });
196
- workflow.file?.addOverride("permissions", { contents: "read" });
197
- workflow.on({ push: { tags: [`${tagPrefix}*`] } });
198
- workflow.addJob("publish", {
199
- runsOn: ["ubuntu-latest"],
200
- // `id-token: write` lets npm mint the OIDC token for provenance attestation.
201
- permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
202
- timeoutMinutes: 30,
203
- defaults: { run: { workingDirectory: directory } },
204
- env: { CI: "true" },
222
+ this.authorPublishWorkflow(project, {
223
+ name,
224
+ tagPrefix,
225
+ workingDirectory: directory,
205
226
  steps: [
206
- { name: "Checkout", uses: "actions/checkout@v6", with: { "fetch-depth": 0 } },
207
- { name: "Setup pnpm", uses: "pnpm/action-setup@v5", with: { version: "10.33.0" } },
208
- {
209
- name: "Setup Node.js",
210
- uses: "actions/setup-node@v6",
211
- with: { "node-version": "lts/*", "registry-url": "https://registry.npmjs.org" },
212
- },
213
- // NOT frozen. The lockfile is gitignored by policy (it can carry a
214
- // private-registry fingerprint), so CI often has none or a stale one -
215
- // and `--frozen-lockfile` turns that into a hard release failure rather
216
- // than resolving. A blocked publish is worse here than a resolved one.
217
- { name: "Install", run: "pnpm install --no-frozen-lockfile" },
218
227
  // The pushed tag is the version: `<prefix>1.2.3` -> `1.2.3`. package.json
219
228
  // is projen-generated read-only, so unlock it before `npm version` writes.
220
229
  {
package/tasks/barrels.ts CHANGED
@@ -19,11 +19,28 @@ if (process.argv.includes("--watch")) {
19
19
  watchLoop("barrels", watchRoots(), (changed) => {
20
20
  const pkgDirs = recordedPackages().map((p) => p.dir);
21
21
  const dirs = new Set<string>();
22
+ const unowned: string[] = [];
22
23
  for (const p of changed) {
23
24
  const owner = ownerPackageDir(p, pkgDirs);
24
25
  if (owner) dirs.add(owner);
26
+ else unowned.push(p);
25
27
  }
26
- const n = generateBarrels(dirs.size ? { dirs: [...dirs] } : {});
28
+ // A change under a package root that no RECORDED package owns is almost always a
29
+ // NEW package folder: it has no `pnpm-workspace.yaml` member yet, so there is no
30
+ // barrel for this watcher to target and only a re-synth can create one. Say so,
31
+ // rather than rebuilding every barrel in the repo - the previous fallback, which
32
+ // did a full-repo sweep on behalf of a file it could not barrel anyway, and in
33
+ // doing so raced the projenrc watcher's own post-synth sweep.
34
+ if (dirs.size === 0) {
35
+ if (unowned.length) {
36
+ logger.warn(
37
+ `no recorded package owns ${string.pluralize(unowned.length, "change")}; ` +
38
+ "run `pnpm exec projen` (or touch .projenrc.ts) to pick up a new package folder",
39
+ );
40
+ }
41
+ return;
42
+ }
43
+ const n = generateBarrels({ dirs: [...dirs] });
27
44
  if (n) logger.success(`rebuilt ${string.pluralize(n, "barrel")}`);
28
45
  });
29
46
  } else {
package/tasks/bump.ts CHANGED
@@ -39,11 +39,11 @@
39
39
  * - `false`: never publish locally.
40
40
  * - a URL: always publish to that registry.
41
41
  */
42
- import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
43
- import { resolve } from "node:path";
44
- import { Command, Option } from "commander";
45
42
  import { exec, project } from "@dbx-tools/core";
46
43
  import { log, net } from "@dbx-tools/shared-core";
44
+ import { Command, Option } from "commander";
45
+ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
46
+ import { resolve } from "node:path";
47
47
 
48
48
  const logger = log.logger("projen:bump");
49
49
  const LEVELS = ["patch", "minor", "major"] as const;
@@ -229,7 +229,7 @@ program
229
229
  const tags = prefixes.map((prefix) => `${prefix}${version}`);
230
230
  logger.info(
231
231
  `bump ${base.join(".")} -> ${version} (${opts.level}); tags ${tags.join(", ")}` +
232
- `${tagged.length ? "" : " [no remote tag]"}`,
232
+ `${tagged.length ? "" : " [no remote tag]"}`,
233
233
  );
234
234
  for (const t of tagged) {
235
235
  if (compareSemver(t.version, base) < 0) {
@@ -288,8 +288,7 @@ program
288
288
  stdin: "ignore",
289
289
  check: true,
290
290
  });
291
- const runInRepo = (command: string, args: string[]) =>
292
- runIn(process.cwd(), command, args);
291
+ const runInRepo = (command: string, args: string[]) => runIn(process.cwd(), command, args);
293
292
  // Each package keeps `version: 0.0.0` on disk (projen owns the
294
293
  // manifest); the root bump above only touched the root. Mirror the CI
295
294
  // `release` workflow: stamp the release version on EVERY package (they're