@dbx-tools/projen 0.9.7 → 0.9.9

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/index.ts CHANGED
@@ -3,7 +3,7 @@
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.7";
6
+ export const PACKAGE_VERSION = "0.9.9";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -36,6 +36,7 @@ export * as tsconfig from "./src/tsconfig.ts";
36
36
  export * as uniffi from "./src/uniffi.ts";
37
37
  export * as vscode from "./src/vscode.ts";
38
38
  export * as watch from "./src/watch.ts";
39
+ export * as workspaceLock from "./src/workspace-lock.ts";
39
40
  export * as workspaceVersion from "./src/workspace-version.ts";
40
41
  export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
41
42
  export { BUN_VERSION } from "./src/bun-workflow.ts";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.7",
34
- "@dbx-tools/path": "^0.9.7",
35
- "@dbx-tools/shared-core": "^0.9.7",
33
+ "@dbx-tools/core": "^0.9.9",
34
+ "@dbx-tools/path": "^0.9.9",
35
+ "@dbx-tools/shared-core": "^0.9.9",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "main": "index.ts",
55
55
  "license": "Apache-2.0",
56
- "version": "0.9.7",
56
+ "version": "0.9.9",
57
57
  "types": "index.ts",
58
58
  "type": "module",
59
59
  "exports": {
@@ -26,6 +26,19 @@ export interface RustCliOptions {
26
26
  readonly command?: string;
27
27
  /** Root CLI help text. Defaults to the Rust package description. */
28
28
  readonly description?: string;
29
+ /** Keep the binary installable by registry lookup without exposing a root command. */
30
+ readonly hidden?: boolean;
31
+ }
32
+
33
+ /** One explicit Cargo binary target and its optional release registration. */
34
+ export interface RustBinaryOptions {
35
+ readonly name: string;
36
+ readonly path: string;
37
+ readonly requiredFeatures?: readonly string[];
38
+ readonly release?: boolean;
39
+ readonly releaseExcludeOs?: readonly RustReleaseOs[];
40
+ readonly description?: string;
41
+ readonly cli?: boolean | RustCliOptions;
29
42
  }
30
43
 
31
44
  /** Explicit Cargo example target and the feature gate required to compile it. */
@@ -47,6 +60,8 @@ export interface RustCrateOptions {
47
60
  /** Omit this crate and any release binary artifact from these operating systems. */
48
61
  readonly releaseExcludeOs?: readonly RustReleaseOs[];
49
62
  readonly dependencies?: Readonly<Record<string, CargoDependency>>;
63
+ /** Cargo dependencies scoped by a target cfg expression. */
64
+ readonly targetDependencies?: Readonly<Record<string, Readonly<Record<string, CargoDependency>>>>;
50
65
  readonly devDependencies?: Readonly<Record<string, CargoDependency>>;
51
66
  readonly features?: Readonly<Record<string, readonly string[]>>;
52
67
  readonly defaultFeatures?: readonly string[];
@@ -54,6 +69,10 @@ export interface RustCrateOptions {
54
69
  readonly examples?: readonly CargoExampleOptions[];
55
70
  /** Cargo and release executable name. Defaults to the generated package name. */
56
71
  readonly binaryName?: string;
72
+ /** Additional Cargo binary targets owned by this crate. */
73
+ readonly binaries?: readonly RustBinaryOptions[];
74
+ /** Cargo binary selected by `cargo run` when this crate has multiple binaries. */
75
+ readonly defaultRun?: string;
57
76
  /** Publish this release binary through the generated `dbx` command registry. */
58
77
  readonly cli?: boolean | RustCliOptions;
59
78
  readonly bindings?: readonly ("node" | "python")[];
@@ -187,10 +206,12 @@ function packageOptions(
187
206
  ...(options.release !== undefined ? { release: options.release } : {}),
188
207
  ...(options.releaseExcludeOs ? { releaseExcludeOs: options.releaseExcludeOs } : {}),
189
208
  ...(options.dependencies ? { dependencies: options.dependencies } : {}),
209
+ ...(options.targetDependencies ? { targetDependencies: options.targetDependencies } : {}),
190
210
  ...(options.devDependencies ? { devDependencies: options.devDependencies } : {}),
191
211
  ...(options.features ? { features: options.features } : {}),
192
212
  ...(options.defaultFeatures ? { defaultFeatures: options.defaultFeatures } : {}),
193
213
  ...(options.binaryName ? { binaryName: options.binaryName } : {}),
214
+ ...(options.binaries ? { binaries: options.binaries } : {}),
194
215
  ...(options.cli !== undefined ? { cli: options.cli } : {}),
195
216
  ...(options.bindings ? { bindings: options.bindings } : {}),
196
217
  ...(options.uniffiConfig ? { uniffiConfig: options.uniffiConfig } : {}),
@@ -244,6 +265,7 @@ export class RustProject extends Project implements DBXToolsProject {
244
265
  ...(!workspace ? { workspace: {} } : {}),
245
266
  package: {
246
267
  name: this.crateName,
268
+ ...(options.defaultRun ? { "default-run": options.defaultRun } : {}),
247
269
  version: inheritedCargoValue(workspace, options.version, "0.1.0"),
248
270
  edition: inheritedCargoValue(workspace, options.edition, "2021"),
249
271
  "rust-version": inheritedCargoValue(workspace, options.rustVersion, "1.82"),
@@ -272,6 +294,13 @@ export class RustProject extends Project implements DBXToolsProject {
272
294
  ? [{ name: `${this.crateName}-uniffi-bindgen`, path: "uniffi-bindgen.rs" }]
273
295
  : []),
274
296
  ...(binary ? [{ name: binaryName, path: "src/main.rs" }] : []),
297
+ ...(options.binaries ?? []).map((target) => ({
298
+ name: target.name,
299
+ path: target.path,
300
+ ...(target.requiredFeatures?.length
301
+ ? { "required-features": [...target.requiredFeatures] }
302
+ : {}),
303
+ })),
275
304
  ],
276
305
  }
277
306
  : {}),
@@ -304,6 +333,23 @@ export class RustProject extends Project implements DBXToolsProject {
304
333
  ),
305
334
  }
306
335
  : {}),
336
+ ...(options.targetDependencies
337
+ ? {
338
+ target: Object.fromEntries(
339
+ Object.entries(options.targetDependencies).map(([target, dependencies]) => [
340
+ target,
341
+ {
342
+ dependencies: Object.fromEntries(
343
+ Object.entries(dependencies).map(([name, value]) => [
344
+ name,
345
+ cargoDependency(value, dependencyVersion),
346
+ ]),
347
+ ),
348
+ },
349
+ ]),
350
+ ),
351
+ }
352
+ : {}),
307
353
  ...(options.devDependencies
308
354
  ? {
309
355
  "dev-dependencies": Object.fromEntries(
@@ -128,6 +128,7 @@ interface RustReleaseBinaryPlan {
128
128
  readonly crate: string;
129
129
  readonly binary: string;
130
130
  readonly excludedOs: readonly RustReleaseOs[];
131
+ readonly requiredFeatures: readonly string[];
131
132
  }
132
133
 
133
134
  type RustReleaseTargetPlan = Readonly<Record<string, string | boolean | number>>;
@@ -165,13 +166,26 @@ export function planRustRelease(
165
166
  nodePackage: binding.nodePackage ?? "",
166
167
  pythonPackage: binding.pythonPackage ?? "",
167
168
  }));
168
- const releaseBinaries = packages
169
- .filter((pkg) => pkg.packageOptions.release)
170
- .map((pkg) => ({
171
- crate: pkg.crateName,
172
- binary: pkg.packageOptions.binaryName ?? pkg.crateName,
173
- excludedOs: pkg.packageOptions.releaseExcludeOs ?? [],
174
- }));
169
+ const releaseBinaries = packages.flatMap((pkg) => [
170
+ ...(pkg.packageOptions.release
171
+ ? [
172
+ {
173
+ crate: pkg.crateName,
174
+ binary: pkg.packageOptions.binaryName ?? pkg.crateName,
175
+ excludedOs: pkg.packageOptions.releaseExcludeOs ?? [],
176
+ requiredFeatures: [],
177
+ },
178
+ ]
179
+ : []),
180
+ ...(pkg.packageOptions.binaries ?? [])
181
+ .filter((binary) => binary.release)
182
+ .map((binary) => ({
183
+ crate: pkg.crateName,
184
+ binary: binary.name,
185
+ excludedOs: binary.releaseExcludeOs ?? [],
186
+ requiredFeatures: binary.requiredFeatures ?? [],
187
+ })),
188
+ ]);
175
189
  const publicCrates = orderRustBindings(
176
190
  packages
177
191
  .filter((pkg) => !pkg.packageOptions.private)
@@ -338,7 +352,31 @@ function rustBinaryCommands(plan: RustReleasePlan, independent: boolean): string
338
352
  });
339
353
  }
340
354
 
355
+ function rustAdditionalBinaryBuildCommands(plan: RustReleasePlan, independent: boolean): string[] {
356
+ return plan.releaseBinaries
357
+ .filter((binary) => binary.requiredFeatures.length)
358
+ .flatMap((binary) => {
359
+ const command = `cargo build --release --package "${binary.crate}" --bin "${binary.binary}" --features "${binary.requiredFeatures.join(",")}"${
360
+ plan.usesCargoLock ? " --locked" : ""
361
+ } --target "\${{ matrix.cargo }}"`;
362
+ const excludedCondition = binary.excludedOs
363
+ .map((os) => `[ "\${{ matrix.os }}" != "${os}" ]`)
364
+ .join(" && ");
365
+ const platformCommands = excludedCondition
366
+ ? [`if ${excludedCondition}; then`, ` ${command}`, "fi"]
367
+ : [command];
368
+ if (!independent) return platformCommands;
369
+ const unit = defaultReleaseUnitId("rust", binary.crate);
370
+ return [
371
+ `if jq -e --arg unit "${unit}" '.units[] | select(.id == $unit)' dist/release-plan.json >/dev/null; then`,
372
+ ...platformCommands.map((line) => ` ${line}`),
373
+ "fi",
374
+ ];
375
+ });
376
+ }
377
+
341
378
  function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
379
+ const binaryCrates = [...new Set(plan.releaseBinaries.map((binary) => binary.crate))];
342
380
  return [
343
381
  ...plan.bindings.flatMap((binding) => [
344
382
  ...(binding.node
@@ -368,24 +406,35 @@ function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
368
406
  ]
369
407
  : []),
370
408
  ]),
371
- ...plan.releaseBinaries.map((pkg) => ({
372
- name: `Upload ${pkg.crate} release binary`,
373
- uses: "actions/upload-artifact@v7",
374
- ...(rustReleaseBinaryCondition(pkg.excludedOs)
375
- ? { if: rustReleaseBinaryCondition(pkg.excludedOs) }
376
- : {}),
377
- with: {
378
- name: `${pkg.crate}-\${{ matrix.node }}-binary`,
379
- path: `dist/release/${pkg.crate}/\${{ matrix.node }}/binary/*`,
380
- "retention-days": 7,
381
- },
382
- })),
409
+ ...binaryCrates.map((crate) => {
410
+ const binaries = plan.releaseBinaries.filter((binary) => binary.crate === crate);
411
+ const excludedOs =
412
+ binaries[0]?.excludedOs.filter((os) =>
413
+ binaries.every((binary) => binary.excludedOs.includes(os)),
414
+ ) ?? [];
415
+ return {
416
+ name: `Upload ${crate} release binary`,
417
+ uses: "actions/upload-artifact@v7",
418
+ ...(rustReleaseBinaryCondition(excludedOs)
419
+ ? { if: rustReleaseBinaryCondition(excludedOs) }
420
+ : {}),
421
+ with: {
422
+ name: `${crate}-\${{ matrix.node }}-binary`,
423
+ path: `dist/release/${crate}/\${{ matrix.node }}/binary/*`,
424
+ "retention-days": 7,
425
+ },
426
+ };
427
+ }),
383
428
  ];
384
429
  }
385
430
 
386
431
  export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep[]): Job {
387
432
  const bindingCommands = rustBindingCommands(plan, Boolean(independentSetup));
388
433
  const binaryCommands = rustBinaryCommands(plan, Boolean(independentSetup));
434
+ const additionalBinaryBuildCommands = rustAdditionalBinaryBuildCommands(
435
+ plan,
436
+ Boolean(independentSetup),
437
+ );
389
438
  return {
390
439
  if: independentSetup
391
440
  ? "${{ needs.release-plan.outputs.rust_targets != '[]' && (github.event_name == 'push' || inputs.stage != 'docs') }}"
@@ -524,16 +573,19 @@ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly
524
573
  },
525
574
  run: timedBash(
526
575
  "rust_workspace",
527
- independentSetup
528
- ? [
529
- "mapfile -t PACKAGES < <(jq -r '.[]' <<<'${{ toJSON(matrix.packages) }}')",
530
- "PACKAGE_ARGS=()",
531
- 'for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_ARGS+=(--package "$PACKAGE"); done',
532
- `cargo build --release --timings "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
533
- ].join("\n")
534
- : `cargo build --release --timings --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
535
- plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
536
- }`,
576
+ [
577
+ independentSetup
578
+ ? [
579
+ "mapfile -t PACKAGES < <(jq -r '.[]' <<<'${{ toJSON(matrix.packages) }}')",
580
+ "PACKAGE_ARGS=()",
581
+ 'for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_ARGS+=(--package "$PACKAGE"); done',
582
+ `cargo build --release --timings "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
583
+ ].join("\n")
584
+ : `cargo build --release --timings --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
585
+ plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
586
+ }`,
587
+ ...additionalBinaryBuildCommands,
588
+ ].join("\n"),
537
589
  ),
538
590
  },
539
591
  ...(!independentSetup
package/src/project-rs.ts CHANGED
@@ -289,9 +289,11 @@ export interface RustReleaseBinaryMapping {
289
289
  readonly command: string;
290
290
  readonly description: string;
291
291
  readonly binaryName: string;
292
+ readonly hidden: boolean;
292
293
  readonly unit: string;
293
294
  readonly component: string;
294
295
  readonly version: string;
296
+ readonly tagPrefix: string;
295
297
  readonly tag: string;
296
298
  readonly repository: string;
297
299
  readonly assets: readonly RustReleaseBinaryAssetMapping[];
@@ -379,17 +381,40 @@ function planRustReleaseBinaries(
379
381
  resolved: ResolvedRustWorkspaceOptions,
380
382
  ): RustReleaseBinaryMapping[] {
381
383
  const commands = new Set<string>();
382
- const binaries = packages
383
- .filter((pkg) => Boolean(pkg.packageOptions.cli))
384
- .map((pkg) => {
385
- if (!pkg.packageOptions.release) {
384
+ const binaries = packages.flatMap((pkg) => {
385
+ const configuredBinaries = [
386
+ ...(pkg.packageOptions.cli
387
+ ? [
388
+ {
389
+ name: pkg.packageOptions.binaryName ?? pkg.crateName,
390
+ defaultCommand: pkg.packageOptions.directory,
391
+ description: pkg.packageOptions.description,
392
+ release: pkg.packageOptions.release,
393
+ excludedOs: pkg.packageOptions.releaseExcludeOs,
394
+ cli: pkg.packageOptions.cli,
395
+ },
396
+ ]
397
+ : []),
398
+ ...(pkg.packageOptions.binaries ?? [])
399
+ .filter((binary) => Boolean(binary.cli))
400
+ .map((binary) => ({
401
+ name: binary.name,
402
+ defaultCommand: binary.name,
403
+ description: binary.description ?? pkg.packageOptions.description,
404
+ release: binary.release,
405
+ excludedOs: binary.releaseExcludeOs,
406
+ cli: binary.cli!,
407
+ })),
408
+ ];
409
+ return configuredBinaries.map((binary) => {
410
+ if (!binary.release) {
386
411
  throw new Error(`${pkg.crateName} must set release when cli is configured`);
387
412
  }
388
- const configured = pkg.packageOptions.cli;
413
+ const configured = binary.cli;
389
414
  const command =
390
415
  typeof configured === "object" && configured.command
391
416
  ? configured.command
392
- : pkg.packageOptions.directory;
417
+ : binary.defaultCommand;
393
418
  if (!/^[a-z0-9][a-z0-9-]*$/.test(command)) {
394
419
  throw new Error(`Invalid Rust CLI command: ${command}`);
395
420
  }
@@ -399,24 +424,31 @@ function planRustReleaseBinaries(
399
424
  commands.add(command);
400
425
  const description =
401
426
  (typeof configured === "object" ? configured.description : undefined) ??
402
- pkg.packageOptions.description ??
427
+ binary.description ??
403
428
  pkg.crateName;
404
- const excludedOs = new Set(pkg.packageOptions.releaseExcludeOs ?? []);
405
- const binaryName = pkg.packageOptions.binaryName ?? pkg.crateName;
429
+ const hidden = typeof configured === "object" && configured.hidden === true;
430
+ const excludedOs = new Set(binary.excludedOs ?? []);
431
+ const binaryName = binary.name;
406
432
  const identity = isDBXToolsJavaScriptProject()(project)
407
433
  ? project.releaseCatalog.releaseIdentityFor(pkg)
408
434
  : {
409
435
  component: defaultReleaseUnitId("rust", pkg.crateName),
410
436
  version: readWorkspaceVersion(project.outdir),
411
437
  };
438
+ const tagPrefix =
439
+ isDBXToolsJavaScriptProject()(project) && project.releaseCatalog.mode === "independent"
440
+ ? `${identity.component}-v`
441
+ : "v";
412
442
  return {
413
443
  command,
414
444
  description,
415
445
  binaryName,
446
+ hidden,
416
447
  unit: "id" in identity ? identity.id : identity.component,
417
448
  component: identity.component,
418
449
  version: identity.version,
419
- tag: `${identity.component}-v${identity.version}`,
450
+ tagPrefix,
451
+ tag: `${tagPrefix}${identity.version}`,
420
452
  repository: resolved.repository,
421
453
  assets: resolved.nativeTargets
422
454
  .filter((target) => !excludedOs.has(target.os))
@@ -427,6 +459,7 @@ function planRustReleaseBinaries(
427
459
  })),
428
460
  };
429
461
  });
462
+ });
430
463
  if (binaries.length && !resolved.repository) {
431
464
  throw new Error("A repository URL is required when Rust CLI commands are configured");
432
465
  }
@@ -894,6 +927,10 @@ export class DBXToolsRustWorkspace extends Component {
894
927
  identity: pkg.crateName,
895
928
  publish: !pkg.packageOptions.private || pkg.packageOptions.release === true,
896
929
  sourcePaths: [`${toPosix(relative(project.outdir, pkg.outdir))}/src`],
930
+ });
931
+ }
932
+ for (const pkg of this.packages) {
933
+ project.releaseCatalog.configureProject(pkg, {
897
934
  dependencies: [
898
935
  ...rustReleaseDependencies(
899
936
  project,
@@ -921,7 +958,7 @@ export class DBXToolsRustWorkspace extends Component {
921
958
  if (isDBXToolsJavaScriptProject()(project)) {
922
959
  for (const binary of this.releaseBinaries) {
923
960
  project.releaseCatalog.registerArtifact(binary.unit, {
924
- id: `${binary.unit}:github-binary`,
961
+ id: `${binary.unit}:github-binary:${binary.binaryName}`,
925
962
  kind: "github-binary",
926
963
  name: binary.binaryName,
927
964
  generated: true,
@@ -311,7 +311,10 @@ export class DBXToolsReleaseCatalog extends Component {
311
311
  /** Replace grouping metadata for an already registered project. */
312
312
  configureProject(
313
313
  project: Project,
314
- options: Pick<ReleaseProjectRegistration, "unit" | "component" | "publish" | "sourcePaths">,
314
+ options: Pick<
315
+ ReleaseProjectRegistration,
316
+ "unit" | "component" | "publish" | "sourcePaths" | "dependencies"
317
+ >,
315
318
  ): void {
316
319
  const current = this.projects.get(project);
317
320
  if (!current) throw new Error(`Release project is not registered: ${project.outdir}`);
package/src/watch.ts CHANGED
@@ -19,6 +19,7 @@ import { isAbsolute, resolve } from "node:path";
19
19
  import { watch as pathWatch } from "@dbx-tools/path";
20
20
  import { asyncUtils, log } from "@dbx-tools/shared-core";
21
21
  import { isGeneratedFile, recordedRoots, repoRoot } from "./packages.ts";
22
+ import { withWorkspaceMutationLock } from "./workspace-lock.ts";
22
23
 
23
24
  const logger = log.logger("projen:watch");
24
25
  const DEBOUNCE_MS = 250;
@@ -103,7 +104,9 @@ export function watchLoop(
103
104
  .filter((p) => !ignoredPath(p));
104
105
  pending.clear();
105
106
  try {
106
- if (relevant.length) await onBatch(relevant);
107
+ if (relevant.length) {
108
+ await withWorkspaceMutationLock(repoRoot, () => onBatch(relevant));
109
+ }
107
110
  } catch (err) {
108
111
  logger.error(`${tag} cycle failed:`, err instanceof Error ? err.message : err);
109
112
  } finally {
@@ -0,0 +1,22 @@
1
+ /** Cross-process coordination for generated workspace mutations. */
2
+ import { resolve } from "node:path";
3
+ import * as fileLock from "@dbx-tools/core/file-lock";
4
+ import { log } from "@dbx-tools/shared-core";
5
+
6
+ const MUTATION_LOCK_SCOPE = "dbx-tools-workspace-mutation";
7
+ const logger = log.logger("projen:workspace-lock");
8
+
9
+ /**
10
+ * Serialize synthesis, generated-file watchers, and release preparation for one
11
+ * repository without coordinating unrelated worktrees or repositories.
12
+ */
13
+ export function withWorkspaceMutationLock<T>(
14
+ root: string,
15
+ callback: () => T | Promise<T>,
16
+ ): Promise<T> {
17
+ const repository = resolve(root);
18
+ return fileLock.withFileLock([MUTATION_LOCK_SCOPE, repository], callback, {
19
+ backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
20
+ onWait: () => logger.info("waiting for workspace mutation lock", { repository }),
21
+ });
22
+ }
@@ -4,7 +4,7 @@ import { arch, platform } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
- import { log } from "@dbx-tools/shared-core";
7
+ import { json, log, object } from "@dbx-tools/shared-core";
8
8
  import { parse, stringify } from "smol-toml";
9
9
  import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
10
10
  import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
@@ -42,6 +42,7 @@ function pythonTag(): string {
42
42
  "uv",
43
43
  [
44
44
  "run",
45
+ "--no-project",
45
46
  "python",
46
47
  "-c",
47
48
  "import sysconfig; print(sysconfig.get_platform().replace('-', '_').replace('.', '_'))",
@@ -94,17 +95,90 @@ function cargoVersionExists(crateName: string, version: string, registry: string
94
95
  ]);
95
96
  }
96
97
 
98
+ interface CargoMetadataPackage {
99
+ dependencies: Array<{ name: string; source?: string | null }>;
100
+ manifest_path: string;
101
+ name: string;
102
+ }
103
+
104
+ function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
105
+ const configured = new Set(config.crates.map((crate) => resolve(repoRoot, crate, "Cargo.toml")));
106
+ const metadata = json.parseRecord(
107
+ run("cargo", ["metadata", "--format-version", "1", "--no-deps", "--locked"], true),
108
+ );
109
+ if (!metadata || !Array.isArray(metadata.packages)) {
110
+ throw new Error("Cargo metadata did not return workspace packages");
111
+ }
112
+ const packages = metadata.packages.filter(object.isRecord).map((pkg) => {
113
+ if (
114
+ typeof pkg.name !== "string" ||
115
+ typeof pkg.manifest_path !== "string" ||
116
+ !Array.isArray(pkg.dependencies)
117
+ ) {
118
+ throw new Error("Cargo metadata returned an invalid package");
119
+ }
120
+ return {
121
+ name: pkg.name,
122
+ manifest_path: resolve(pkg.manifest_path),
123
+ dependencies: pkg.dependencies.filter(object.isRecord).map((dependency) => {
124
+ if (typeof dependency.name !== "string") {
125
+ throw new Error(`Cargo metadata returned an invalid dependency for ${pkg.name}`);
126
+ }
127
+ return {
128
+ name: dependency.name,
129
+ source: typeof dependency.source === "string" ? dependency.source : null,
130
+ };
131
+ }),
132
+ } satisfies CargoMetadataPackage;
133
+ });
134
+ const publishable = new Map(
135
+ packages
136
+ .filter((pkg) => configured.has(pkg.manifest_path))
137
+ .filter((pkg) => {
138
+ const manifest = parse(readFileSync(pkg.manifest_path, "utf8")) as {
139
+ package?: { publish?: boolean };
140
+ };
141
+ return manifest.package?.publish !== false;
142
+ })
143
+ .map((pkg) => [pkg.name, pkg]),
144
+ );
145
+ const ordered: CargoMetadataPackage[] = [];
146
+ const visiting = new Set<string>();
147
+ const completed = new Set<string>();
148
+ const visit = (pkg: CargoMetadataPackage): void => {
149
+ if (completed.has(pkg.name)) return;
150
+ if (visiting.has(pkg.name)) {
151
+ throw new Error(`Cyclic Cargo publication dependency: ${pkg.name}`);
152
+ }
153
+ visiting.add(pkg.name);
154
+ for (const dependency of pkg.dependencies) {
155
+ if (dependency.source) continue;
156
+ const workspaceDependency = publishable.get(dependency.name);
157
+ if (workspaceDependency) visit(workspaceDependency);
158
+ }
159
+ visiting.delete(pkg.name);
160
+ completed.add(pkg.name);
161
+ ordered.push(pkg);
162
+ };
163
+ for (const pkg of publishable.values()) visit(pkg);
164
+ return ordered.map((pkg) => pkg.manifest_path);
165
+ }
166
+
97
167
  function publishCargo(config: RustWorkspaceMapping, registry: string, version: string): void {
98
- const manifests = config.crates
99
- .map((crate) => resolve(repoRoot, crate, "Cargo.toml"))
100
- .filter((manifest) => !/^publish = false$/m.test(readFileSync(manifest, "utf8")));
168
+ const manifests = orderedCargoManifests(config);
101
169
  const originals = new Map(
102
170
  manifests.map((manifest) => [manifest, readFileSync(manifest, "utf8")]),
103
171
  );
104
172
  try {
105
- const crateNames = manifests.map(
106
- (manifest) => readFileSync(manifest, "utf8").match(/^name = "([^"]+)"$/m)?.[1],
107
- );
173
+ const crateNames = manifests.map((manifest) => {
174
+ const document = parse(readFileSync(manifest, "utf8")) as {
175
+ package?: { name?: string };
176
+ };
177
+ if (!document.package?.name) {
178
+ throw new Error(`Cargo manifest has no package name: ${manifest}`);
179
+ }
180
+ return document.package.name;
181
+ });
108
182
  for (const manifest of manifests) {
109
183
  const mode = statSync(manifest).mode;
110
184
  const document = parse(readFileSync(manifest, "utf8")) as Record<string, unknown>;
@@ -48,6 +48,7 @@ import {
48
48
  resolveBaseVersion,
49
49
  resolveNextVersion,
50
50
  } from "../src/workspace-version.ts";
51
+ import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
51
52
 
52
53
  const logger = log.logger("projen:release");
53
54
 
@@ -303,227 +304,237 @@ program
303
304
  waitTimeoutMinutes: string;
304
305
  }) => {
305
306
  const root = projectUtils.root() ?? process.cwd();
306
- const waitTimeoutMinutes = object.toNumber(opts.waitTimeoutMinutes, {
307
- separators: false,
308
- percent: false,
309
- });
310
- if (
311
- waitTimeoutMinutes === undefined ||
312
- !Number.isInteger(waitTimeoutMinutes) ||
313
- waitTimeoutMinutes <= 0
314
- ) {
315
- throw new Error("--wait-timeout-minutes must be a positive integer");
316
- }
317
- const waitTimeoutMs = waitTimeoutMinutes * 60_000;
318
- const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
319
- if (!currentBranch) throw new Error("Release preparation requires a local branch");
320
- const account = githubAccount(root);
321
- git(root, ["fetch", "--tags", "origin", opts.base]);
322
- const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
323
- fetch: false,
324
- includeComponentTags: false,
325
- }).version;
326
- const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
327
- const releaseVersionScript = fileURLToPath(new URL("./release-version.ts", import.meta.url));
328
- runTaskCommand(root, process.execPath, [
329
- releaseVersionScript,
330
- "--version",
331
- next.version,
332
- "--prefix",
333
- opts.prefix,
334
- "--assert-next",
335
- ]);
336
- const releaseTag = `${opts.prefix}${next.version}`;
337
- const releaseBranch = `release/${releaseTag}`;
338
- const releaseRoot = join(root, ".worktrees", releaseTag);
339
- if (currentBranch.startsWith("release/")) {
340
- throw new Error("Release preparation must start from a source branch");
341
- }
342
- if (
343
- git(root, ["ls-remote", "--tags", "origin", `refs/tags/${releaseTag}`], {
344
- capture: true,
345
- check: false,
346
- })
347
- ) {
348
- throw new Error(`Release tag already exists: ${releaseTag}`);
349
- }
350
-
351
- const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"], {
352
- capture: true,
353
- });
354
- if (status) {
355
- git(root, ["add", "-A"]);
356
- git(root, ["commit", "-m", opts.message]);
357
- }
358
- if (!gitSucceeds(root, ["merge-base", "--is-ancestor", `origin/${opts.base}`, "HEAD"])) {
359
- git(root, ["merge", "--no-edit", `origin/${opts.base}`]);
360
- }
361
- pushCurrentBranch(root, currentBranch);
362
-
363
- const worktreeExists = existsSync(join(releaseRoot, ".git"));
364
- if (!worktreeExists) {
365
- git(root, ["worktree", "prune"]);
366
- const localBranch = git(root, ["branch", "--list", releaseBranch], { capture: true });
367
- const remoteBranch = git(
368
- root,
369
- ["ls-remote", "--heads", "origin", `refs/heads/${releaseBranch}`],
370
- { capture: true, check: false },
371
- );
372
- if (localBranch || remoteBranch) {
373
- throw new Error(`Release branch already exists without its worktree: ${releaseBranch}`);
307
+ await withWorkspaceMutationLock(root, async () => {
308
+ const waitTimeoutMinutes = object.toNumber(opts.waitTimeoutMinutes, {
309
+ separators: false,
310
+ percent: false,
311
+ });
312
+ if (
313
+ waitTimeoutMinutes === undefined ||
314
+ !Number.isInteger(waitTimeoutMinutes) ||
315
+ waitTimeoutMinutes <= 0
316
+ ) {
317
+ throw new Error("--wait-timeout-minutes must be a positive integer");
374
318
  }
375
- git(root, ["worktree", "add", "-b", releaseBranch, releaseRoot, "HEAD"]);
376
- } else {
377
- const releaseStatus = git(
378
- releaseRoot,
379
- ["status", "--porcelain=v1", "--untracked-files=all"],
380
- { capture: true },
319
+ const waitTimeoutMs = waitTimeoutMinutes * 60_000;
320
+ const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
321
+ if (!currentBranch) throw new Error("Release preparation requires a local branch");
322
+ const account = githubAccount(root);
323
+ git(root, ["fetch", "--tags", "origin", opts.base]);
324
+ const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
325
+ fetch: false,
326
+ includeComponentTags: false,
327
+ }).version;
328
+ const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
329
+ const releaseVersionScript = fileURLToPath(
330
+ new URL("./release-version.ts", import.meta.url),
381
331
  );
382
- if (releaseStatus) {
383
- git(releaseRoot, ["stash", "push", "--include-untracked", "--message", "release-resume"]);
332
+ runTaskCommand(root, process.execPath, [
333
+ releaseVersionScript,
334
+ "--version",
335
+ next.version,
336
+ "--prefix",
337
+ opts.prefix,
338
+ "--assert-next",
339
+ ]);
340
+ const releaseTag = `${opts.prefix}${next.version}`;
341
+ const releaseBranch = `release/${releaseTag}`;
342
+ const releaseRoot = join(root, ".worktrees", releaseTag);
343
+ if (currentBranch.startsWith("release/")) {
344
+ throw new Error("Release preparation must start from a source branch");
345
+ }
346
+ if (
347
+ git(root, ["ls-remote", "--tags", "origin", `refs/tags/${releaseTag}`], {
348
+ capture: true,
349
+ check: false,
350
+ })
351
+ ) {
352
+ throw new Error(`Release tag already exists: ${releaseTag}`);
384
353
  }
385
- if (!gitSucceeds(releaseRoot, ["merge-base", "--is-ancestor", currentBranch, "HEAD"])) {
386
- git(releaseRoot, ["merge", "--no-edit", currentBranch]);
354
+
355
+ const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"], {
356
+ capture: true,
357
+ });
358
+ if (status) {
359
+ git(root, ["add", "-A"]);
360
+ git(root, ["commit", "-m", opts.message]);
387
361
  }
388
- if (releaseStatus) {
389
- git(releaseRoot, ["stash", "pop"]);
362
+ if (!gitSucceeds(root, ["merge-base", "--is-ancestor", `origin/${opts.base}`, "HEAD"])) {
363
+ git(root, ["merge", "--no-edit", `origin/${opts.base}`]);
390
364
  }
391
- logger.info(`resuming ${releaseBranch} in ${releaseRoot}`);
392
- }
393
- runTaskCommand(releaseRoot, process.execPath, ["install"]);
365
+ pushCurrentBranch(root, currentBranch);
394
366
 
395
- const bumpScript = fileURLToPath(new URL("./bump.ts", import.meta.url));
396
- const versionCheckScript = fileURLToPath(new URL("./version-check.ts", import.meta.url));
397
- runTaskCommand(releaseRoot, process.execPath, [
398
- bumpScript,
399
- "--level",
400
- opts.level,
401
- "--prefix",
402
- opts.prefix,
403
- ...opts.os.flatMap((value) => ["--os", value]),
404
- ...opts.arch.flatMap((value) => ["--arch", value]),
405
- ]);
406
- if (readWorkspaceVersion(releaseRoot) !== next.version) {
407
- throw new Error(`Release preparation did not produce ${next.version}`);
408
- }
367
+ const worktreeExists = existsSync(join(releaseRoot, ".git"));
368
+ if (!worktreeExists) {
369
+ git(root, ["worktree", "prune"]);
370
+ const localBranch = git(root, ["branch", "--list", releaseBranch], { capture: true });
371
+ const remoteBranch = git(
372
+ root,
373
+ ["ls-remote", "--heads", "origin", `refs/heads/${releaseBranch}`],
374
+ { capture: true, check: false },
375
+ );
376
+ if (localBranch || remoteBranch) {
377
+ throw new Error(`Release branch already exists without its worktree: ${releaseBranch}`);
378
+ }
379
+ git(root, ["worktree", "add", "-b", releaseBranch, releaseRoot, "HEAD"]);
380
+ } else {
381
+ const releaseStatus = git(
382
+ releaseRoot,
383
+ ["status", "--porcelain=v1", "--untracked-files=all"],
384
+ { capture: true },
385
+ );
386
+ if (releaseStatus) {
387
+ git(releaseRoot, [
388
+ "stash",
389
+ "push",
390
+ "--include-untracked",
391
+ "--message",
392
+ "release-resume",
393
+ ]);
394
+ }
395
+ if (!gitSucceeds(releaseRoot, ["merge-base", "--is-ancestor", currentBranch, "HEAD"])) {
396
+ git(releaseRoot, ["merge", "--no-edit", currentBranch]);
397
+ }
398
+ if (releaseStatus) {
399
+ git(releaseRoot, ["stash", "pop"]);
400
+ }
401
+ logger.info(`resuming ${releaseBranch} in ${releaseRoot}`);
402
+ }
403
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
409
404
 
410
- runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
411
- if (opts.validate) {
412
- for (const task of opts.validateTask) {
413
- runTaskCommand(releaseRoot, process.execPath, ["run", task]);
405
+ const bumpScript = fileURLToPath(new URL("./bump.ts", import.meta.url));
406
+ const versionCheckScript = fileURLToPath(new URL("./version-check.ts", import.meta.url));
407
+ runTaskCommand(releaseRoot, process.execPath, [
408
+ bumpScript,
409
+ "--level",
410
+ opts.level,
411
+ "--prefix",
412
+ opts.prefix,
413
+ ...opts.os.flatMap((value) => ["--os", value]),
414
+ ...opts.arch.flatMap((value) => ["--arch", value]),
415
+ ]);
416
+ if (readWorkspaceVersion(releaseRoot) !== next.version) {
417
+ throw new Error(`Release preparation did not produce ${next.version}`);
414
418
  }
415
- if (existsSync(join(releaseRoot, "Cargo.toml"))) {
416
- runTaskCommand(releaseRoot, "cargo", [
417
- "test",
418
- "--workspace",
419
- ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
420
- ]);
419
+
420
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
421
+ if (opts.validate) {
422
+ for (const task of opts.validateTask) {
423
+ runTaskCommand(releaseRoot, process.execPath, ["run", task]);
424
+ }
425
+ if (existsSync(join(releaseRoot, "Cargo.toml"))) {
426
+ runTaskCommand(releaseRoot, "cargo", [
427
+ "test",
428
+ "--workspace",
429
+ ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
430
+ ]);
431
+ }
432
+ runTaskCommand(releaseRoot, process.execPath, ["run", "compile"]);
433
+ } else {
434
+ logger.warn("release validation skipped by --no-validate");
421
435
  }
422
- runTaskCommand(releaseRoot, process.execPath, ["run", "compile"]);
423
- } else {
424
- logger.warn("release validation skipped by --no-validate");
425
- }
426
- if (opts.localPublish) {
427
- await publishLocalRelease({
428
- root: releaseRoot,
429
- version: next.version,
430
- localRegistry: opts.localRegistry,
431
- localPypi: opts.localPypi,
432
- pythonRoot: opts.pythonRoot,
433
- localCargo: opts.localCargo,
434
- reuseValidatedNodeCompile: opts.validate,
435
- });
436
- } else {
437
- logger.info("local publication skipped by --no-local-publish");
438
- }
439
- runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
440
- const releaseSummary = opts.releaseSummary
441
- ? await generateReleaseSummary({
436
+ if (opts.localPublish) {
437
+ await publishLocalRelease({
442
438
  root: releaseRoot,
443
439
  version: next.version,
444
- fromRef: `${opts.prefix}${comparisonBase}`,
445
- providers: releaseSummaryProviders(opts.releaseSummaryProviders),
446
- })
447
- : undefined;
440
+ localRegistry: opts.localRegistry,
441
+ localPypi: opts.localPypi,
442
+ pythonRoot: opts.pythonRoot,
443
+ localCargo: opts.localCargo,
444
+ reuseValidatedNodeCompile: opts.validate,
445
+ });
446
+ } else {
447
+ logger.info("local publication skipped by --no-local-publish");
448
+ }
449
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
450
+ const releaseSummary = opts.releaseSummary
451
+ ? await generateReleaseSummary({
452
+ root: releaseRoot,
453
+ version: next.version,
454
+ fromRef: `${opts.prefix}${comparisonBase}`,
455
+ providers: releaseSummaryProviders(opts.releaseSummaryProviders),
456
+ })
457
+ : undefined;
448
458
 
449
- git(releaseRoot, ["add", "-A"]);
450
- const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
451
- if (staged) {
452
- git(releaseRoot, ["commit", "-m", `chore(release): ${next.version}`]);
453
- } else if (!worktreeExists) {
454
- throw new Error("Release preparation produced no changes");
455
- }
456
- // The source push and release commit hooks have already scanned every new
457
- // byte. A new remote branch has no upstream comparison point, so the
458
- // managed pre-push hook would rescan the repository's complete history.
459
- git(releaseRoot, ["push", "--no-verify", "--set-upstream", "origin", releaseBranch]);
459
+ git(releaseRoot, ["add", "-A"]);
460
+ const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
461
+ if (staged) {
462
+ git(releaseRoot, ["commit", "-m", `chore(release): ${next.version}`]);
463
+ } else if (!worktreeExists) {
464
+ throw new Error("Release preparation produced no changes");
465
+ }
466
+ // The source push and release commit hooks have already scanned every new
467
+ // byte. A new remote branch has no upstream comparison point, so the
468
+ // managed pre-push hook would rescan the repository's complete history.
469
+ git(releaseRoot, ["push", "--no-verify", "--set-upstream", "origin", releaseBranch]);
460
470
 
461
- const title = `chore(release): ${next.version}`;
462
- const body = [
463
- `Release ${releaseTag}.`,
464
- "",
465
- `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
466
- "",
467
- "Merging this PR updates VERSION on main and starts the public release workflow.",
468
- ...(releaseSummary ? ["", releaseSummary] : []),
469
- ].join("\n");
470
- const githubEnvironment = {
471
- ...process.env,
472
- GH_HOST: account.hostname,
473
- GH_REPO: githubRepositorySpecifier(account),
474
- [githubTokenEnvironmentName(account.hostname)]: account.token,
475
- };
476
- const ensurePullRequest = (): void => {
477
- if (
478
- taskCommandSucceeds(root, "gh", ["pr", "view", releaseBranch], {
471
+ const title = `chore(release): ${next.version}`;
472
+ const body = [
473
+ `Release ${releaseTag}.`,
474
+ "",
475
+ `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
476
+ "",
477
+ "Merging this PR updates VERSION on main and starts the public release workflow.",
478
+ ...(releaseSummary ? ["", releaseSummary] : []),
479
+ ].join("\n");
480
+ const githubEnvironment = {
481
+ ...process.env,
482
+ GH_HOST: account.hostname,
483
+ GH_REPO: githubRepositorySpecifier(account),
484
+ [githubTokenEnvironmentName(account.hostname)]: account.token,
485
+ };
486
+ const ensurePullRequest = (): void => {
487
+ if (
488
+ taskCommandSucceeds(root, "gh", ["pr", "view", releaseBranch], {
489
+ env: githubEnvironment,
490
+ })
491
+ ) {
492
+ return;
493
+ }
494
+ runTaskCommand(
495
+ root,
496
+ "gh",
497
+ [
498
+ "pr",
499
+ "create",
500
+ "--base",
501
+ opts.base,
502
+ "--head",
503
+ releaseBranch,
504
+ "--title",
505
+ title,
506
+ "--body",
507
+ body,
508
+ ],
509
+ { env: githubEnvironment },
510
+ );
511
+ };
512
+ ensurePullRequest();
513
+ if (opts.approve) {
514
+ runTaskCommand(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], {
479
515
  env: githubEnvironment,
480
- })
481
- ) {
482
- return;
516
+ });
483
517
  }
484
- runTaskCommand(
485
- root,
486
- "gh",
487
- [
488
- "pr",
489
- "create",
490
- "--base",
491
- opts.base,
492
- "--head",
518
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
519
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
520
+ if (opts.approve && opts.wait) {
521
+ const mergeSha = await waitForPullRequestMerge(
522
+ root,
493
523
  releaseBranch,
494
- "--title",
495
- title,
496
- "--body",
497
- body,
498
- ],
499
- { env: githubEnvironment },
500
- );
501
- };
502
- ensurePullRequest();
503
- if (opts.approve) {
504
- runTaskCommand(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], {
505
- env: githubEnvironment,
506
- });
507
- }
508
- git(root, ["worktree", "remove", "--force", releaseRoot]);
509
- git(root, ["branch", "--delete", "--force", releaseBranch]);
510
- if (opts.approve && opts.wait) {
511
- const mergeSha = await waitForPullRequestMerge(
512
- root,
513
- releaseBranch,
514
- githubEnvironment,
515
- waitTimeoutMs,
524
+ githubEnvironment,
525
+ waitTimeoutMs,
526
+ );
527
+ logger.info("release pull request merged", { releaseBranch, mergeSha });
528
+ await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
529
+ logger.success(`published ${releaseTag} from ${mergeSha}`);
530
+ return;
531
+ }
532
+ logger.success(
533
+ `${
534
+ opts.approve ? "enabled automatic merge for" : "opened"
535
+ } ${releaseBranch} for ${releaseTag}${opts.approve && !opts.wait ? " without waiting" : ""}`,
516
536
  );
517
- logger.info("release pull request merged", { releaseBranch, mergeSha });
518
- await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
519
- logger.success(`published ${releaseTag} from ${mergeSha}`);
520
- return;
521
- }
522
- logger.success(
523
- `${
524
- opts.approve ? "enabled automatic merge for" : "opened"
525
- } ${releaseBranch} for ${releaseTag}${opts.approve && !opts.wait ? " without waiting" : ""}`,
526
- );
537
+ });
527
538
  },
528
539
  );
529
540
 
package/tasks/sync.ts CHANGED
@@ -4,6 +4,7 @@ import { log } from "@dbx-tools/shared-core";
4
4
  import concurrently from "concurrently";
5
5
  import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
6
6
  import { runSynth } from "../src/scaffold.ts";
7
+ import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
7
8
 
8
9
  const logger = log.logger("projen:sync");
9
10
 
@@ -39,7 +40,7 @@ if (!process.argv.includes("--watch")) {
39
40
  // One-shot: full synth (+install + barrels via the post-synth component). This is
40
41
  // the scriptable path, so a failed synth stays a failed exit code.
41
42
  logger.start("synthesizing");
42
- runSynth({ post: true });
43
+ await withWorkspaceMutationLock(repoRoot, () => runSynth({ post: true }));
43
44
  logger.success("synced");
44
45
  } else {
45
46
  // Watch: one initial full synth to bring the tree up to date, then focused
@@ -52,7 +53,7 @@ if (!process.argv.includes("--watch")) {
52
53
  // fatal: errors are logged and retried, and only a stop signal ends the task.
53
54
  logger.start("initial sync");
54
55
  try {
55
- runSynth({ post: true });
56
+ await withWorkspaceMutationLock(repoRoot, () => runSynth({ post: true }));
56
57
  logger.success("synced - watching (Ctrl-C to stop)");
57
58
  } catch (err) {
58
59
  // A tree that doesn't synth is precisely when the watcher is most useful: the edit