@dbx-tools/projen 0.9.6 → 0.9.8

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.
@@ -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
@@ -95,9 +96,9 @@ if (!process.argv.includes("--watch")) {
95
96
  * re-emitting it is how we say "stop" in a language the supervisor understands.
96
97
  *
97
98
  * Killing is asynchronous (concurrently shells out to `ps` to walk each process tree)
98
- * and can outlive `result`, which is how a plain SIGTERM used to leave orphaned
99
- * watchers behind. The timer holds the process open until the kills land, and doubles
100
- * as the backstop that leaves anyway if one of them wedges.
99
+ * and can outlive `result`. The timer keeps the process open until the kills
100
+ * land, which prevents orphaned watchers after SIGTERM. It also stops waiting
101
+ * if one of them wedges.
101
102
  */
102
103
  function stop(): void {
103
104
  if (stopping) return;