@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.
package/src/release.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Unified default-branch release workflow generation. */
2
+ import { stringUtils } from "@dbx-tools/shared-core";
2
3
  import { Component } from "projen";
3
4
  import { GithubWorkflow } from "projen/lib/github";
4
5
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
@@ -205,47 +206,52 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
205
206
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
206
207
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
207
208
  },
208
- run: [
209
- 'if [ "$GITHUB_EVENT_NAME" = "push" ]; then',
210
- ' test "$GITHUB_REF_TYPE" = "branch"',
211
- ` test "$GITHUB_REF_NAME" = "${releaseBranch}"`,
212
- ' RELEASE_SHA="$GITHUB_SHA"',
213
- ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
214
- " RELEASE_VERSION=\"$(tr -d '\\r\\n' < VERSION)\"",
215
- ' bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"',
216
- ' PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d \'\\r\\n\')"',
217
- ' test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"',
218
- ` RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"`,
219
- ' if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then',
220
- ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
221
- ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
222
- ' test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"',
223
- " else",
224
- " git fetch --force --tags origin",
225
- ` bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION" --prefix ${JSON.stringify(tagPrefix)} --assert-next`,
226
- ' git config user.name "github-actions[bot]"',
227
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
228
- ' git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"',
229
- ' git push origin "refs/tags/$RELEASE_TAG"',
230
- " fi",
231
- "else",
232
- ` case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
233
- ` RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"`,
234
- ' bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"',
235
- ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
236
- ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
237
- ' RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
238
- ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
239
- ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
240
- ' if [ -n "${{ inputs.source_run_id }}" ]; then',
241
- ' case "${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac',
242
- ' case "${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac',
243
- " fi",
244
- "fi",
245
- 'echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"',
246
- 'echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"',
247
- 'echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"',
248
- ].join("\n"),
209
+ // prettier-ignore
210
+ run: stringUtils.dedent(
211
+ // ============================================================================
212
+ /*bash*/`
213
+ if [ "$GITHUB_EVENT_NAME" = "push" ]; then
214
+ test "$GITHUB_REF_TYPE" = "branch"
215
+ test "$GITHUB_REF_NAME" = "${releaseBranch}"
216
+ RELEASE_SHA="$GITHUB_SHA"
217
+ test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
218
+ RELEASE_VERSION="$(tr -d '\\r\\n' < VERSION)"
219
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
220
+ PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d '\\r\\n')"
221
+ test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"
222
+ RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"
223
+ if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then
224
+ git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
225
+ test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
226
+ test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"
227
+ else
228
+ git fetch --force --tags origin
229
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION" --prefix ${JSON.stringify(tagPrefix)} --assert-next
230
+ git config user.name "github-actions[bot]"
231
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
232
+ git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"
233
+ git push origin "refs/tags/$RELEASE_TAG"
234
+ fi
235
+ else
236
+ case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac
237
+ RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"
238
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
239
+ git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
240
+ test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
241
+ RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"
242
+ test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
243
+ test "$RELEASE_SHA" = "$EXPECTED_SHA"
244
+ if [ -n "\${{ inputs.source_run_id }}" ]; then
245
+ case "\${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac
246
+ case "\${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac
247
+ fi
248
+ fi
249
+ echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
250
+ echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
251
+ echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
252
+ `
253
+ // ============================================================================
254
+ ),
249
255
  },
250
256
  {
251
257
  name: "Verify source artifact run",
@@ -256,15 +262,20 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
256
262
  SOURCE_RUN_ID: "${{ inputs.source_run_id }}",
257
263
  },
258
264
  with: {
259
- script: [
260
- "const run = await github.rest.actions.getWorkflowRun({",
261
- " owner: context.repo.owner,",
262
- " repo: context.repo.repo,",
263
- " run_id: Number(process.env.SOURCE_RUN_ID),",
264
- "});",
265
- 'if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");',
266
- 'if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");',
267
- ].join("\n"),
265
+ // prettier-ignore
266
+ script: stringUtils.dedent(
267
+ // ============================================================================
268
+ /*js*/`
269
+ const run = await github.rest.actions.getWorkflowRun({
270
+ owner: context.repo.owner,
271
+ repo: context.repo.repo,
272
+ run_id: Number(process.env.SOURCE_RUN_ID),
273
+ });
274
+ if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");
275
+ if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");
276
+ `
277
+ // ============================================================================
278
+ ),
268
279
  },
269
280
  },
270
281
  ],
@@ -422,44 +433,49 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
422
433
  RELEASE_PRS: "${{ steps.release.outputs.prs }}",
423
434
  },
424
435
  shell: "bash",
425
- run: [
426
- 'BRANCH="$(jq -r \'.[0].headBranchName\' <<<"$RELEASE_PRS")"',
427
- 'test -n "$BRANCH" && test "$BRANCH" != "null"',
428
- 'git fetch origin "$BRANCH"',
429
- 'git switch --force-create "$BRANCH" "origin/$BRANCH"',
430
- "bun install",
431
- "bunx projen",
432
- "bun node_modules/@dbx-tools/projen/tasks/version-check.ts",
433
- `bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}`,
434
- `bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}`,
435
- 'if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then',
436
- ' git config user.name "github-actions[bot]"',
437
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
438
- " git add -A",
439
- ' git commit -m "chore: reconcile release metadata"',
440
- ' git push origin "HEAD:$BRANCH"',
441
- "fi",
442
- 'MERGED="false"',
443
- "for ATTEMPT in $(seq 1 15); do",
444
- ' STATE="$(gh pr view "$BRANCH" --json state,mergeable --jq \'[.state,.mergeable] | join(" ")\')"',
445
- ' if [[ "$STATE" == MERGED* ]]; then MERGED="true"; break; fi',
446
- ' if [ "$STATE" = "OPEN CONFLICTING" ]; then',
447
- ' echo "::error::release pull request has merge conflicts"',
448
- " exit 1",
449
- " fi",
450
- ' if [ "$STATE" = "OPEN MERGEABLE" ] && gh pr merge "$BRANCH" --merge; then',
451
- ' MERGED="true"',
452
- " break",
453
- " fi",
454
- ' echo "release pull request is not mergeable yet (attempt $ATTEMPT/15)"',
455
- " sleep 2",
456
- "done",
457
- 'if [ "$MERGED" != "true" ]; then',
458
- ' echo "::error::release pull request did not become mergeable"',
459
- " exit 1",
460
- "fi",
461
- 'gh workflow run release.yml --ref "$RELEASE_BRANCH" -f automatic=true',
462
- ].join("\n"),
436
+ // prettier-ignore
437
+ run: stringUtils.dedent(
438
+ // ============================================================================
439
+ /*bash*/`
440
+ BRANCH="$(jq -r '.[0].headBranchName' <<<"$RELEASE_PRS")"
441
+ test -n "$BRANCH" && test "$BRANCH" != "null"
442
+ git fetch origin "$BRANCH"
443
+ git switch --force-create "$BRANCH" "origin/$BRANCH"
444
+ bun install
445
+ bunx projen
446
+ bun node_modules/@dbx-tools/projen/tasks/version-check.ts
447
+ bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}
448
+ bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}
449
+ if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then
450
+ git config user.name "github-actions[bot]"
451
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
452
+ git add -A
453
+ git commit -m "chore: reconcile release metadata"
454
+ git push origin "HEAD:$BRANCH"
455
+ fi
456
+ MERGED="false"
457
+ for ATTEMPT in $(seq 1 15); do
458
+ STATE="$(gh pr view "$BRANCH" --json state,mergeable --jq '[.state,.mergeable] | join(" ")')"
459
+ if [[ "$STATE" == MERGED* ]]; then MERGED="true"; break; fi
460
+ if [ "$STATE" = "OPEN CONFLICTING" ]; then
461
+ echo "::error::release pull request has merge conflicts"
462
+ exit 1
463
+ fi
464
+ if [ "$STATE" = "OPEN MERGEABLE" ] && gh pr merge "$BRANCH" --merge; then
465
+ MERGED="true"
466
+ break
467
+ fi
468
+ echo "release pull request is not mergeable yet (attempt $ATTEMPT/15)"
469
+ sleep 2
470
+ done
471
+ if [ "$MERGED" != "true" ]; then
472
+ echo "::error::release pull request did not become mergeable"
473
+ exit 1
474
+ fi
475
+ gh workflow run release.yml --ref "$RELEASE_BRANCH" -f automatic=true
476
+ `
477
+ // ============================================================================
478
+ ),
463
479
  },
464
480
  ],
465
481
  };
@@ -499,22 +515,27 @@ function configureReleaseRequestWorkflow(
499
515
  name: "Read release request",
500
516
  id: "request",
501
517
  shell: "bash",
502
- run: [
503
- 'MESSAGE="$(git log -1 --format=%B)"',
504
- 'if ! grep -q "^Release-Request: true$" <<<"$MESSAGE"; then',
505
- ' echo "requested=false" >> "$GITHUB_OUTPUT"',
506
- " exit 0",
507
- "fi",
508
- `git fetch origin ${JSON.stringify(baseBranch)}`,
509
- `if git diff --quiet "origin/${baseBranch}...HEAD"; then`,
510
- ' echo "requested=false" >> "$GITHUB_OUTPUT"',
511
- " exit 0",
512
- "fi",
513
- 'echo "requested=true" >> "$GITHUB_OUTPUT"',
514
- 'echo "branch=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"',
515
- 'echo "notes_path=$(sed -n \'s/^Release-Notes-Path: //p\' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"',
516
- 'echo "title=$(git log -1 --skip=1 --format=%s)" >> "$GITHUB_OUTPUT"',
517
- ].join("\n"),
518
+ // prettier-ignore
519
+ run: stringUtils.dedent(
520
+ // ============================================================================
521
+ /*bash*/`
522
+ MESSAGE="$(git log -1 --format=%B)"
523
+ if ! grep -q "^Release-Request: true$" <<<"$MESSAGE"; then
524
+ echo "requested=false" >> "$GITHUB_OUTPUT"
525
+ exit 0
526
+ fi
527
+ git fetch origin ${JSON.stringify(baseBranch)}
528
+ if git diff --quiet "origin/${baseBranch}...HEAD"; then
529
+ echo "requested=false" >> "$GITHUB_OUTPUT"
530
+ exit 0
531
+ fi
532
+ echo "requested=true" >> "$GITHUB_OUTPUT"
533
+ echo "branch=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
534
+ echo "notes_path=$(sed -n 's/^Release-Notes-Path: //p' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"
535
+ echo "title=$(git log -1 --skip=1 --format=%s)" >> "$GITHUB_OUTPUT"
536
+ `
537
+ // ============================================================================
538
+ ),
518
539
  },
519
540
  {
520
541
  name: "Create, merge, and release source pull request",
@@ -527,20 +548,25 @@ function configureReleaseRequestWorkflow(
527
548
  NOTES_PATH: "${{ steps.request.outputs.notes_path }}",
528
549
  },
529
550
  shell: "bash",
530
- run: [
531
- 'test -f "$NOTES_PATH"',
532
- 'NOTES="$(cat "$NOTES_PATH")"',
533
- 'BODY="$(printf \'## Release notes\\n\\n%s\\n\' "$NOTES")"',
534
- 'PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number // empty\')"',
535
- 'if [ -n "$PR" ]; then',
536
- ' gh pr edit "$PR" --title "$TITLE" --body "$BODY"',
537
- "else",
538
- ' gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY"',
539
- ' PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number\')"',
540
- "fi",
541
- 'gh pr merge "$PR" --merge',
542
- 'gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$BASE_BRANCH" -f automatic=true',
543
- ].join("\n"),
551
+ // prettier-ignore
552
+ run: stringUtils.dedent(
553
+ // ============================================================================
554
+ /*bash*/`
555
+ test -f "$NOTES_PATH"
556
+ NOTES="$(cat "$NOTES_PATH")"
557
+ BODY="$(printf '## Release notes\\n\\n%s\\n' "$NOTES")"
558
+ PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[0].number // empty')"
559
+ if [ -n "$PR" ]; then
560
+ gh pr edit "$PR" --title "$TITLE" --body "$BODY"
561
+ else
562
+ gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY"
563
+ PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[0].number')"
564
+ fi
565
+ gh pr merge "$PR" --merge
566
+ gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$BASE_BRANCH" -f automatic=true
567
+ `
568
+ // ============================================================================
569
+ ),
544
570
  },
545
571
  ],
546
572
  });
@@ -672,20 +698,25 @@ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
672
698
  {
673
699
  name: "Remove consumed release notes",
674
700
  shell: "bash",
675
- run: [
676
- `git fetch origin ${JSON.stringify(projectReleaseBranch(project))}`,
677
- `if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then`,
678
- ' echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"',
679
- " exit 0",
680
- "fi",
681
- "git rm -r --ignore-unmatch .release-notes",
682
- "if ! git diff --cached --quiet; then",
683
- ' git config user.name "github-actions[bot]"',
684
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
685
- ' git commit -m "chore: remove consumed release notes"',
686
- ` git push origin "HEAD:${projectReleaseBranch(project)}"`,
687
- "fi",
688
- ].join("\n"),
701
+ // prettier-ignore
702
+ run: stringUtils.dedent(
703
+ // ============================================================================
704
+ /*bash*/`
705
+ git fetch origin ${JSON.stringify(projectReleaseBranch(project))}
706
+ if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then
707
+ echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"
708
+ exit 0
709
+ fi
710
+ git rm -r --ignore-unmatch .release-notes
711
+ if ! git diff --cached --quiet; then
712
+ git config user.name "github-actions[bot]"
713
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
714
+ git commit -m "chore: remove consumed release notes"
715
+ git push origin "HEAD:${projectReleaseBranch(project)}"
716
+ fi
717
+ `
718
+ // ============================================================================
719
+ ),
689
720
  },
690
721
  ],
691
722
  };
@@ -769,44 +800,49 @@ function addIndependentBranchSyncJob(
769
800
  name: `Safely sync ${branch}`,
770
801
  shell: "bash",
771
802
  env: { SOURCE_BRANCH: branch, RELEASE_BRANCH: releaseBranch },
772
- run: [
773
- 'if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then',
774
- ' echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"',
775
- " exit 0",
776
- "fi",
777
- 'git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"',
778
- 'git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH"',
779
- 'if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" "origin/$RELEASE_BRANCH"; then',
780
- ' git push origin "refs/remotes/origin/$RELEASE_BRANCH:refs/heads/$SOURCE_BRANCH"',
781
- 'elif git merge-base --is-ancestor "origin/$RELEASE_BRANCH" "origin/$SOURCE_BRANCH"; then',
782
- ' echo "source branch $SOURCE_BRANCH already contains released main"',
783
- "else",
784
- ' git config user.name "github-actions[bot]"',
785
- ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
786
- ' git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"',
787
- ' if git merge --no-ff --no-edit "origin/$RELEASE_BRANCH"; then',
788
- ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
789
- " else",
790
- ' SAFE_GENERATED="true"',
791
- " while IFS= read -r FILE; do",
792
- ' case "$FILE" in',
793
- " .projen/release-units.json|.release-please-manifest.json|.release-units/*/source.json|.release-units/*/version.txt|.release-units/*/CHANGELOG.md|Cargo.lock|*/Cargo.toml|*/package.json|*/pyproject.toml|*/index.ts) ;;",
794
- ' *) SAFE_GENERATED="false" ;;',
795
- " esac",
796
- " done < <(git diff --name-only --diff-filter=U)",
797
- ' if [ "$SAFE_GENERATED" = "true" ] && bunx projen; then',
798
- " git add -A",
799
- ' if [ -z "$(git diff --name-only --diff-filter=U)" ]; then',
800
- " git commit --no-edit",
801
- ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
802
- " exit 0",
803
- " fi",
804
- " fi",
805
- " git merge --abort",
806
- ' echo "::warning::source branch $SOURCE_BRANCH has handwritten or unresolved conflicts with released main; leaving it untouched"',
807
- " fi",
808
- "fi",
809
- ].join("\n"),
803
+ // prettier-ignore
804
+ run: stringUtils.dedent(
805
+ // ============================================================================
806
+ /*bash*/`
807
+ if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then
808
+ echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"
809
+ exit 0
810
+ fi
811
+ git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"
812
+ git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH"
813
+ if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" "origin/$RELEASE_BRANCH"; then
814
+ git push origin "refs/remotes/origin/$RELEASE_BRANCH:refs/heads/$SOURCE_BRANCH"
815
+ elif git merge-base --is-ancestor "origin/$RELEASE_BRANCH" "origin/$SOURCE_BRANCH"; then
816
+ echo "source branch $SOURCE_BRANCH already contains released main"
817
+ else
818
+ git config user.name "github-actions[bot]"
819
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
820
+ git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"
821
+ if git merge --no-ff --no-edit "origin/$RELEASE_BRANCH"; then
822
+ git push origin "HEAD:refs/heads/$SOURCE_BRANCH"
823
+ else
824
+ SAFE_GENERATED="true"
825
+ while IFS= read -r FILE; do
826
+ case "$FILE" in
827
+ .projen/release-units.json|.release-please-manifest.json|.release-units/*/source.json|.release-units/*/version.txt|.release-units/*/CHANGELOG.md|Cargo.lock|*/Cargo.toml|*/package.json|*/pyproject.toml|*/index.ts) ;;
828
+ *) SAFE_GENERATED="false" ;;
829
+ esac
830
+ done < <(git diff --name-only --diff-filter=U)
831
+ if [ "$SAFE_GENERATED" = "true" ] && bunx projen; then
832
+ git add -A
833
+ if [ -z "$(git diff --name-only --diff-filter=U)" ]; then
834
+ git commit --no-edit
835
+ git push origin "HEAD:refs/heads/$SOURCE_BRANCH"
836
+ exit 0
837
+ fi
838
+ fi
839
+ git merge --abort
840
+ echo "::warning::source branch $SOURCE_BRANCH has handwritten or unresolved conflicts with released main; leaving it untouched"
841
+ fi
842
+ fi
843
+ `
844
+ // ============================================================================
845
+ ),
810
846
  },
811
847
  ],
812
848
  });
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";
@@ -94,17 +94,90 @@ function cargoVersionExists(crateName: string, version: string, registry: string
94
94
  ]);
95
95
  }
96
96
 
97
+ interface CargoMetadataPackage {
98
+ dependencies: Array<{ name: string; source?: string | null }>;
99
+ manifest_path: string;
100
+ name: string;
101
+ }
102
+
103
+ function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
104
+ const configured = new Set(config.crates.map((crate) => resolve(repoRoot, crate, "Cargo.toml")));
105
+ const metadata = json.parseRecord(
106
+ run("cargo", ["metadata", "--format-version", "1", "--no-deps", "--locked"], true),
107
+ );
108
+ if (!metadata || !Array.isArray(metadata.packages)) {
109
+ throw new Error("Cargo metadata did not return workspace packages");
110
+ }
111
+ const packages = metadata.packages.filter(object.isRecord).map((pkg) => {
112
+ if (
113
+ typeof pkg.name !== "string" ||
114
+ typeof pkg.manifest_path !== "string" ||
115
+ !Array.isArray(pkg.dependencies)
116
+ ) {
117
+ throw new Error("Cargo metadata returned an invalid package");
118
+ }
119
+ return {
120
+ name: pkg.name,
121
+ manifest_path: resolve(pkg.manifest_path),
122
+ dependencies: pkg.dependencies.filter(object.isRecord).map((dependency) => {
123
+ if (typeof dependency.name !== "string") {
124
+ throw new Error(`Cargo metadata returned an invalid dependency for ${pkg.name}`);
125
+ }
126
+ return {
127
+ name: dependency.name,
128
+ source: typeof dependency.source === "string" ? dependency.source : null,
129
+ };
130
+ }),
131
+ } satisfies CargoMetadataPackage;
132
+ });
133
+ const publishable = new Map(
134
+ packages
135
+ .filter((pkg) => configured.has(pkg.manifest_path))
136
+ .filter((pkg) => {
137
+ const manifest = parse(readFileSync(pkg.manifest_path, "utf8")) as {
138
+ package?: { publish?: boolean };
139
+ };
140
+ return manifest.package?.publish !== false;
141
+ })
142
+ .map((pkg) => [pkg.name, pkg]),
143
+ );
144
+ const ordered: CargoMetadataPackage[] = [];
145
+ const visiting = new Set<string>();
146
+ const completed = new Set<string>();
147
+ const visit = (pkg: CargoMetadataPackage): void => {
148
+ if (completed.has(pkg.name)) return;
149
+ if (visiting.has(pkg.name)) {
150
+ throw new Error(`Cyclic Cargo publication dependency: ${pkg.name}`);
151
+ }
152
+ visiting.add(pkg.name);
153
+ for (const dependency of pkg.dependencies) {
154
+ if (dependency.source) continue;
155
+ const workspaceDependency = publishable.get(dependency.name);
156
+ if (workspaceDependency) visit(workspaceDependency);
157
+ }
158
+ visiting.delete(pkg.name);
159
+ completed.add(pkg.name);
160
+ ordered.push(pkg);
161
+ };
162
+ for (const pkg of publishable.values()) visit(pkg);
163
+ return ordered.map((pkg) => pkg.manifest_path);
164
+ }
165
+
97
166
  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")));
167
+ const manifests = orderedCargoManifests(config);
101
168
  const originals = new Map(
102
169
  manifests.map((manifest) => [manifest, readFileSync(manifest, "utf8")]),
103
170
  );
104
171
  try {
105
- const crateNames = manifests.map(
106
- (manifest) => readFileSync(manifest, "utf8").match(/^name = "([^"]+)"$/m)?.[1],
107
- );
172
+ const crateNames = manifests.map((manifest) => {
173
+ const document = parse(readFileSync(manifest, "utf8")) as {
174
+ package?: { name?: string };
175
+ };
176
+ if (!document.package?.name) {
177
+ throw new Error(`Cargo manifest has no package name: ${manifest}`);
178
+ }
179
+ return document.package.name;
180
+ });
108
181
  for (const manifest of manifests) {
109
182
  const mode = statSync(manifest).mode;
110
183
  const document = parse(readFileSync(manifest, "utf8")) as Record<string, unknown>;