@dbx-tools/projen 0.9.17 → 0.9.18

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/project-js.ts CHANGED
@@ -1064,18 +1064,14 @@ class GeneratedSource extends Component {
1064
1064
  *
1065
1065
  * projen gives a monorepo root empty `compile`/`test` tasks - a child's tasks
1066
1066
  * are the child's business - so `bun run build` at the root type-checked nothing
1067
- * and ran no package tests. The fan-out is delegated to bun's own workspace
1068
- * filter rather than one `exec` per member, which matters three ways: bun runs
1069
- * the members in PARALLEL (measured ~2.5x faster across this repo than the
1070
- * sequential per-`cwd` form), a member that does not define the script is
1071
- * skipped instead of needing a guard, and the filter reads the workspace from
1072
- * `package.json` - so it stays correct when a package is added without a
1073
- * re-synth. A non-zero member exit still fails the run.
1067
+ * and ran no package tests. Compilation groups ordinary `tsc --build` members
1068
+ * into a few TypeScript processes and runs custom compile tasks alongside them. Tests
1069
+ * still use Bun's filtered workspace fan-out. Both read the current workspace
1070
+ * list from `package.json`, so a newly added member needs no re-synth.
1074
1071
  *
1075
- * `*` matches every workspace MEMBER and never the root itself, so the root
1072
+ * `*` matches every workspace MEMBER and never the root itself, so the test
1076
1073
  * task delegating to it cannot recurse. Members declared outside the scanned
1077
- * package roots (`extraWorkspaceMembers`) are workspace members too, so they are
1078
- * covered by the same filter.
1074
+ * package roots (`extraWorkspaceMembers`) are covered by both tasks.
1079
1075
  */
1080
1076
  class WorkspaceValidationTasks extends Component {
1081
1077
  private configured = false;
@@ -1087,9 +1083,8 @@ class WorkspaceValidationTasks extends Component {
1087
1083
  if (project.subprojects.length === 0 && project.extraWorkspaceMembers.length === 0) {
1088
1084
  return;
1089
1085
  }
1090
- for (const task of [project.compileTask, project.testTask]) {
1091
- task.exec(`bun run --filter '*' ${task.name}`);
1092
- }
1086
+ project.compileTask.exec(taskScript(project, "compile-workspace.ts"));
1087
+ project.testTask.exec("bun run --filter '*' test");
1093
1088
  }
1094
1089
  }
1095
1090
 
package/src/project-py.ts CHANGED
@@ -7,7 +7,7 @@ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workf
7
7
  import { DBX_TOOLS_LICENSE, projectReleaseBranch, projectRepositoryUrl } from "./project-js.ts";
8
8
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
9
9
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
10
- import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
10
+ import { RELEASE_TAG, RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
11
11
  import {
12
12
  independentReleaseSetupSteps,
13
13
  registerIndependentPublicationJob,
@@ -465,9 +465,9 @@ export class DBXToolsPythonWorkspace extends Component {
465
465
  if (project.releaseCatalog.mode === "independent") {
466
466
  workflow.addJob("build-python", {
467
467
  if: usesRustArtifacts
468
- ? "${{ always() && needs.release-plan.outputs.python == 'true' && needs.rust-uniffi.result != 'failure' && needs.rust-uniffi.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
468
+ ? "${{ always() && needs.release-plan.outputs.python == 'true' && needs.rust-build.result != 'failure' && needs.rust-build.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
469
469
  : "${{ needs.release-plan.outputs.python == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}",
470
- needs: ["release-plan", ...(usesRustArtifacts ? ["rust-uniffi"] : [])],
470
+ needs: ["release-plan", ...(usesRustArtifacts ? ["rust-build"] : [])],
471
471
  runsOn: ["ubuntu-latest"],
472
472
  permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
473
473
  timeoutMinutes: 20,
@@ -546,9 +546,9 @@ export class DBXToolsPythonWorkspace extends Component {
546
546
  }
547
547
  workflow.addJob("build-python", {
548
548
  if: usesRustArtifacts
549
- ? "${{ always() && needs.verify-context.result == 'success' && needs.rust-uniffi.result != 'failure' && needs.rust-uniffi.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
549
+ ? "${{ always() && needs.verify-context.result == 'success' && needs.rust-assets.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
550
550
  : releaseStageCondition("python"),
551
- needs: ["verify-context", ...(usesRustArtifacts ? ["rust-uniffi"] : [])],
551
+ needs: ["verify-context", ...(usesRustArtifacts ? ["rust-assets"] : [])],
552
552
  runsOn: ["ubuntu-latest"],
553
553
  permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
554
554
  timeoutMinutes: 20,
@@ -567,8 +567,27 @@ export class DBXToolsPythonWorkspace extends Component {
567
567
  recoveredName: `Download recovered ${publication.distribution} native wheels`,
568
568
  pattern: `${publication.distribution}--*--python-wheel`,
569
569
  path: `dist/${publication.directory}`,
570
+ condition: "needs.verify-context.outputs.build_mode == 'remote'",
570
571
  }),
571
572
  ),
573
+ ...(uniffiPublications.length
574
+ ? [
575
+ {
576
+ name: "Download locally built native wheels",
577
+ if: "${{ needs.verify-context.outputs.build_mode == 'local' }}",
578
+ env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG },
579
+ shell: "bash",
580
+ run: [
581
+ "mkdir -p dist/local-wheels",
582
+ 'gh release download "$RELEASE_TAG" --pattern "*.whl" --dir dist/local-wheels',
583
+ ...uniffiPublications.flatMap((publication) => [
584
+ `mkdir -p dist/${publication.directory}`,
585
+ `cp dist/local-wheels/${publication.distribution.replaceAll("-", "_")}-*.whl dist/${publication.directory}/`,
586
+ ]),
587
+ ].join("\n"),
588
+ },
589
+ ]
590
+ : []),
572
591
  {
573
592
  name: "Stamp workspace versions",
574
593
  env: { VERSION: RELEASE_VERSION },
package/src/project-rs.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve } from "node:path";
4
4
  import * as projectUtils from "@dbx-tools/core/project-utils";
5
5
  import { stringUtils } from "@dbx-tools/shared-core";
6
6
  import { Component, Project, TextFile, TomlFile, javascript } from "projen";
7
+ import { releaseBinaryAssetName } from "./_release-platform.ts";
7
8
  import {
8
9
  RustProject,
9
10
  type CargoExampleOptions as RustCargoExampleOptions,
@@ -18,6 +19,7 @@ import {
18
19
  type RustOpenApiOptions as RustOpenApiConfiguration,
19
20
  } from "./_rust-project.ts";
20
21
  import {
22
+ rustAssetsJob,
21
23
  configureRustReleaseTask,
22
24
  independentRustCargoPublishJob,
23
25
  independentRustGitHubReleaseJob,
@@ -25,7 +27,6 @@ import {
25
27
  independentRustNodeFacadePublishJob,
26
28
  orderRustBindings,
27
29
  planRustRelease,
28
- releaseBinaryAssetName,
29
30
  rustBuildJob,
30
31
  rustCargoPublishJob,
31
32
  rustGitHubReleaseJob,
@@ -118,6 +119,7 @@ export interface UniFFIReleaseTarget {
118
119
  readonly os: RustReleaseOs;
119
120
  readonly cpu: RustReleaseCpu;
120
121
  readonly libc?: "glibc";
122
+ readonly glibcVersion?: string;
121
123
  }
122
124
 
123
125
  /** Native targets built on matching GitHub-hosted runners. */
@@ -130,6 +132,7 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
130
132
  os: RustReleaseOs.LINUX,
131
133
  cpu: RustReleaseCpu.X64,
132
134
  libc: "glibc",
135
+ glibcVersion: "2.35",
133
136
  },
134
137
  {
135
138
  runner: "ubuntu-24.04-arm",
@@ -139,6 +142,7 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
139
142
  os: RustReleaseOs.LINUX,
140
143
  cpu: RustReleaseCpu.ARM64,
141
144
  libc: "glibc",
145
+ glibcVersion: "2.39",
142
146
  },
143
147
  {
144
148
  runner: "macos-15-intel",
@@ -1098,17 +1102,8 @@ export class DBXToolsRustWorkspace extends Component {
1098
1102
  }
1099
1103
  configureRustReleaseTask(project, plan);
1100
1104
  if (project.releaseCatalog.mode === "independent") {
1101
- if (plan.uniffiTargets.length) {
1102
- workflow.addJob(
1103
- "rust-uniffi",
1104
- rustBuildJob(plan, "uniffi", independentReleaseSetupSteps(project)),
1105
- );
1106
- }
1107
- if (plan.binaryTargets.length) {
1108
- workflow.addJob(
1109
- "rust-binaries",
1110
- rustBuildJob(plan, "binaries", independentReleaseSetupSteps(project)),
1111
- );
1105
+ if (plan.targets.length) {
1106
+ workflow.addJob("rust-build", rustBuildJob(plan, independentReleaseSetupSteps(project)));
1112
1107
  }
1113
1108
  if (plan.publicCrates.length) {
1114
1109
  workflow.addJob("publish-cargo", independentRustCargoPublishJob(project, plan));
@@ -1129,17 +1124,15 @@ export class DBXToolsRustWorkspace extends Component {
1129
1124
  }
1130
1125
  return;
1131
1126
  }
1132
- if (plan.uniffiTargets.length) {
1133
- workflow.addJob("rust-uniffi", rustBuildJob(plan, "uniffi"));
1134
- }
1135
- if (plan.binaryTargets.length) {
1136
- workflow.addJob("rust-binaries", rustBuildJob(plan, "binaries"));
1127
+ if (plan.targets.length) {
1128
+ workflow.addJob("rust-build", rustBuildJob(plan));
1129
+ workflow.addJob("rust-assets", rustAssetsJob());
1137
1130
  }
1138
1131
  if (plan.publicCrates.length) {
1139
1132
  workflow.addJob("publish-cargo", rustCargoPublishJob(plan, false));
1140
1133
  workflow.addJob("publish-local-cargo", rustCargoPublishJob(plan, true));
1141
1134
  }
1142
- if (plan.releaseBinaries.length) {
1135
+ if (plan.hasTargetOutputs) {
1143
1136
  workflow.addJob("publish-github-release", rustGitHubReleaseJob(plan));
1144
1137
  }
1145
1138
  if (plan.nodeBindings.length && hasNodeRelease(project)) {
@@ -28,6 +28,29 @@ export interface GithubAuthenticatedAccount {
28
28
  scopes: string[];
29
29
  }
30
30
 
31
+ /** Repository capabilities returned for one authenticated actor. */
32
+ export interface GithubRepositoryPermissions {
33
+ readonly push: boolean;
34
+ readonly maintain: boolean;
35
+ readonly admin: boolean;
36
+ }
37
+
38
+ /** Read normalized repository permissions from a GitHub repository response. */
39
+ export function githubRepositoryPermissions(value: unknown): GithubRepositoryPermissions {
40
+ const repository = object.isRecord(value) ? value : undefined;
41
+ const permissions = object.isRecord(repository?.permissions) ? repository.permissions : undefined;
42
+ return {
43
+ push: permissions?.push === true,
44
+ maintain: permissions?.maintain === true,
45
+ admin: permissions?.admin === true,
46
+ };
47
+ }
48
+
49
+ /** Whether the actor can manage releases rather than only push source. */
50
+ export function githubRepositoryCanManage(permissions: GithubRepositoryPermissions): boolean {
51
+ return permissions.maintain || permissions.admin;
52
+ }
53
+
31
54
  /** Parse successful GitHub CLI accounts with the active account first. */
32
55
  export function githubAuthenticatedAccounts(
33
56
  statusOutput: string,
@@ -47,10 +47,10 @@ export interface PlannedRustTarget {
47
47
  readonly runner: string;
48
48
  readonly python: string;
49
49
  readonly libc: string;
50
+ readonly glibcVersion: string;
50
51
  readonly packages: readonly string[];
51
52
  readonly binaries: readonly string[];
52
53
  readonly features: readonly string[];
53
- readonly fingerprintConfig: string;
54
54
  }
55
55
 
56
56
  /** Complete plan persisted as a workflow artifact and used for recovery. */
@@ -62,8 +62,7 @@ export interface ReleasePlan {
62
62
  readonly pythonPackages: readonly PlannedPackage[];
63
63
  readonly rustPackages: readonly PlannedPackage[];
64
64
  readonly artifacts: readonly PlannedArtifact[];
65
- readonly rustUniffiTargets: readonly PlannedRustTarget[];
66
- readonly rustBinaryTargets: readonly PlannedRustTarget[];
65
+ readonly rustTargets: readonly PlannedRustTarget[];
67
66
  readonly stages: {
68
67
  readonly rust: boolean;
69
68
  readonly python: boolean;
@@ -133,53 +132,52 @@ export function buildReleasePlan(
133
132
  ]);
134
133
  }
135
134
  }
136
- const rustTargetsByKind = {
137
- uniffi: new Map<string, PlannedRustTarget>(),
138
- binaries: new Map<string, PlannedRustTarget>(),
139
- };
135
+ const rustTargets = new Map<string, PlannedRustTarget>();
140
136
  for (const artifact of artifacts) {
141
137
  const targets = artifact.data?.targets;
142
138
  if (!Array.isArray(targets)) continue;
143
- const kind = artifact.kind === "github-binary" ? "binaries" : "uniffi";
139
+ const binary = artifact.kind === "github-binary";
144
140
  for (const value of targets) {
145
141
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
146
142
  const target = value as Record<string, unknown>;
147
143
  const required = ["os", "cpu", "node", "cargo", "runner", "python"] as const;
148
144
  if (!required.every((field) => typeof target[field] === "string")) continue;
149
145
  const key = String(target.node);
150
- const targetsByKey = rustTargetsByKind[kind];
151
- const currentTarget = targetsByKey.get(key);
146
+ const currentTarget = rustTargets.get(key);
152
147
  const packages = [
153
148
  ...new Set([
154
149
  ...(currentTarget?.packages ?? []),
155
150
  ...(rustPackagesByUnit.get(artifact.unit) ?? []),
156
151
  ]),
157
152
  ].sort();
158
- const binaries =
159
- kind === "binaries"
160
- ? [
161
- ...new Set([
162
- ...(currentTarget?.binaries ?? []),
163
- ...(typeof artifact.data?.binary === "string" ? [artifact.data.binary] : []),
164
- ]),
165
- ].sort()
166
- : [];
153
+ const binaries = binary
154
+ ? [
155
+ ...new Set([
156
+ ...(currentTarget?.binaries ?? []),
157
+ ...(typeof artifact.data?.binary === "string" ? [artifact.data.binary] : []),
158
+ ]),
159
+ ].sort()
160
+ : (currentTarget?.binaries ?? []);
167
161
  const configuredFeatures = Array.isArray(artifact.data?.features)
168
162
  ? artifact.data.features.filter((feature): feature is string => typeof feature === "string")
169
163
  : [];
170
164
  const crate = typeof artifact.data?.crate === "string" ? artifact.data.crate : undefined;
171
- const features =
172
- kind === "uniffi"
173
- ? packages.map((pkg) => `${pkg}/uniffi-bindgen`)
174
- : [
175
- ...new Set([
176
- ...(currentTarget?.features ?? []),
177
- ...(crate
178
- ? configuredFeatures.map((feature) => `${crate}/${feature}`)
179
- : configuredFeatures),
180
- ]),
181
- ].sort();
182
- targetsByKey.set(key, {
165
+ const features = binary
166
+ ? [
167
+ ...new Set([
168
+ ...(currentTarget?.features ?? []),
169
+ ...(crate
170
+ ? configuredFeatures.map((feature) => `${crate}/${feature}`)
171
+ : configuredFeatures),
172
+ ]),
173
+ ].sort()
174
+ : [
175
+ ...new Set([
176
+ ...(currentTarget?.features ?? []),
177
+ ...packages.map((pkg) => `${pkg}/uniffi-bindgen`),
178
+ ]),
179
+ ].sort();
180
+ rustTargets.set(key, {
183
181
  os: String(target.os),
184
182
  cpu: String(target.cpu),
185
183
  node: String(target.node),
@@ -187,19 +185,14 @@ export function buildReleasePlan(
187
185
  runner: String(target.runner),
188
186
  python: String(target.python),
189
187
  libc: typeof target.libc === "string" ? target.libc : "",
188
+ glibcVersion: typeof target.glibcVersion === "string" ? target.glibcVersion : "",
190
189
  packages,
191
190
  binaries,
192
191
  features,
193
- fingerprintConfig: JSON.stringify({ packages, binaries, features }),
194
192
  });
195
193
  }
196
194
  }
197
- const rustUniffiTargets = [...rustTargetsByKind.uniffi.values()].sort((a, b) =>
198
- a.node.localeCompare(b.node),
199
- );
200
- const rustBinaryTargets = [...rustTargetsByKind.binaries.values()].sort((a, b) =>
201
- a.node.localeCompare(b.node),
202
- );
195
+ const plannedRustTargets = [...rustTargets.values()].sort((a, b) => a.node.localeCompare(b.node));
203
196
  const publishBatches = current.publishBatches
204
197
  .map((batch) => batch.filter((unit) => selected.has(unit)))
205
198
  .filter((batch) => batch.length > 0);
@@ -226,8 +219,7 @@ export function buildReleasePlan(
226
219
  pythonPackages,
227
220
  rustPackages,
228
221
  artifacts,
229
- rustUniffiTargets,
230
- rustBinaryTargets,
222
+ rustTargets: plannedRustTargets,
231
223
  stages,
232
224
  omittedStages,
233
225
  };
package/src/release.ts CHANGED
@@ -117,6 +117,7 @@ export function releaseArtifactSteps(options: {
117
117
  readonly recoveredName: string;
118
118
  readonly pattern: string;
119
119
  readonly path: string;
120
+ readonly condition?: string;
120
121
  }): readonly JobStep[] {
121
122
  const shared = {
122
123
  pattern: options.pattern,
@@ -126,13 +127,13 @@ export function releaseArtifactSteps(options: {
126
127
  return [
127
128
  {
128
129
  name: options.currentName,
129
- if: "${{ inputs.source_run_id == '' }}",
130
+ if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id == '' }}`,
130
131
  uses: "actions/download-artifact@v8",
131
132
  with: shared,
132
133
  },
133
134
  {
134
135
  name: options.recoveredName,
135
- if: "${{ inputs.source_run_id != '' }}",
136
+ if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id != '' }}`,
136
137
  uses: "actions/download-artifact@v8",
137
138
  with: {
138
139
  ...shared,
@@ -179,6 +180,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
179
180
  release_tag: { stepId: "release", outputName: "release_tag" },
180
181
  expected_sha: { stepId: "release", outputName: "expected_sha" },
181
182
  release_version: { stepId: "release", outputName: "release_version" },
183
+ build_mode: { stepId: "release", outputName: "build_mode" },
182
184
  },
183
185
  steps: [
184
186
  {
@@ -186,7 +188,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
186
188
  uses: "actions/checkout@v6",
187
189
  with: {
188
190
  ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
189
- "fetch-depth": 2,
191
+ "fetch-depth": 0,
190
192
  },
191
193
  },
192
194
  {
@@ -205,6 +207,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
205
207
  EXPECTED_SHA:
206
208
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
207
209
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
210
+ GH_TOKEN: "${{ github.token }}",
208
211
  },
209
212
  // prettier-ignore
210
213
  run: stringUtils.dedent(
@@ -246,9 +249,15 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
246
249
  case "\${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac
247
250
  fi
248
251
  fi
252
+ BUILD_MODE="$(git log -1 --format=%B -- VERSION | sed -n 's/^Release-Build: //p' | tail -1)"
253
+ case "$BUILD_MODE" in local|remote) ;; *) BUILD_MODE="remote" ;; esac
254
+ if [ "$BUILD_MODE" = "local" ] && ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
255
+ gh release create "$RELEASE_TAG" --draft --title "$RELEASE_TAG" --target "$RELEASE_SHA"
256
+ fi
249
257
  echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
250
258
  echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
251
259
  echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
260
+ echo "build_mode=$BUILD_MODE" >> "$GITHUB_OUTPUT"
252
261
  `
253
262
  // ============================================================================
254
263
  ),
@@ -591,8 +600,7 @@ function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
591
600
  python_packages: { stepId: "plan", outputName: "python_packages" },
592
601
  rust_packages: { stepId: "plan", outputName: "rust_packages" },
593
602
  artifacts: { stepId: "plan", outputName: "artifacts" },
594
- rust_uniffi_targets: { stepId: "plan", outputName: "rust_uniffi_targets" },
595
- rust_binary_targets: { stepId: "plan", outputName: "rust_binary_targets" },
603
+ rust_targets: { stepId: "plan", outputName: "rust_targets" },
596
604
  },
597
605
  steps: [
598
606
  {
@@ -1022,6 +1030,11 @@ export class DBXToolsRelease extends Component {
1022
1030
  description:
1023
1031
  "Prepare, validate, locally publish, and automatically merge a release PR",
1024
1032
  },
1033
+ "release:assets": {
1034
+ exec: taskScript(project, "release-assets.ts"),
1035
+ receiveArgs: true,
1036
+ description: "Build and upload native assets for an existing GitHub Release",
1037
+ },
1025
1038
  }
1026
1039
  : {}),
1027
1040
  });
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Compile source-first workspace packages in a few TypeScript processes. */
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { availableParallelism } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import * as exec from "@dbx-tools/core/exec";
7
+
8
+ interface PackageManifest {
9
+ readonly name?: string;
10
+ readonly scripts?: Record<string, string>;
11
+ readonly workspaces?: string[];
12
+ }
13
+
14
+ interface TaskManifest {
15
+ readonly tasks?: {
16
+ readonly compile?: {
17
+ readonly steps?: Array<{ readonly execArgs?: string[] }>;
18
+ };
19
+ };
20
+ }
21
+
22
+ export interface CompilePlan {
23
+ readonly typescriptConfigs: string[];
24
+ readonly customPackages: Array<{ readonly name: string; readonly directory: string }>;
25
+ }
26
+
27
+ function readJson<T>(path: string): T {
28
+ return JSON.parse(readFileSync(path, "utf8")) as T;
29
+ }
30
+
31
+ function plainTypeScriptBuild(steps: unknown): boolean {
32
+ if (!Array.isArray(steps) || steps.length !== 1) return false;
33
+ const step = steps[0] as { execArgs?: string[] };
34
+ if (!step || typeof step !== "object") return false;
35
+ return (
36
+ Object.keys(step).length === 1 &&
37
+ step.execArgs?.length === 2 &&
38
+ step.execArgs[0] === "tsc" &&
39
+ step.execArgs[1] === "--build"
40
+ );
41
+ }
42
+
43
+ /** Read the root's concrete workspace paths on every run, including newly added members. */
44
+ export function compilePlan(root: string): CompilePlan {
45
+ const workspace = readJson<PackageManifest>(join(root, "package.json"));
46
+ const typescriptConfigs: string[] = [];
47
+ const customPackages: CompilePlan["customPackages"] = [];
48
+ for (const member of workspace.workspaces ?? []) {
49
+ const directory = resolve(root, member);
50
+ const manifest = readJson<PackageManifest>(join(directory, "package.json"));
51
+ const script = manifest.scripts?.compile;
52
+ if (!script) continue;
53
+ const taskPath = join(directory, ".projen", "tasks.json");
54
+ const steps = existsSync(taskPath)
55
+ ? readJson<TaskManifest>(taskPath).tasks?.compile?.steps
56
+ : undefined;
57
+ if (
58
+ script === "projen compile" &&
59
+ !manifest.scripts?.precompile &&
60
+ !manifest.scripts?.postcompile &&
61
+ plainTypeScriptBuild(steps)
62
+ ) {
63
+ typescriptConfigs.push(join(directory, "tsconfig.json"));
64
+ } else {
65
+ customPackages.push({ name: manifest.name ?? member, directory });
66
+ }
67
+ }
68
+ return { typescriptConfigs, customPackages };
69
+ }
70
+
71
+ const emoji = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
72
+
73
+ async function main(): Promise<void> {
74
+ const root = process.cwd();
75
+ const plan = compilePlan(root);
76
+ const jobs: Array<{ name: string; result: Promise<exec.ExecResult> }> = [];
77
+ if (plan.typescriptConfigs.length > 0) {
78
+ const batchCount = Math.min(4, availableParallelism(), plan.typescriptConfigs.length);
79
+ const batches = Array.from({ length: batchCount }, () => [] as string[]);
80
+ plan.typescriptConfigs.forEach((config, index) => batches[index % batchCount]!.push(config));
81
+ console.log(
82
+ `Type-checking ${plan.typescriptConfigs.length} workspaces in ${batchCount} batches`,
83
+ );
84
+ for (const [index, batch] of batches.entries()) {
85
+ jobs.push({
86
+ name: `TypeScript batch ${index + 1}`,
87
+ result: exec.spawn("bunx", ["tsc", "--build", ...batch], {
88
+ cwd: root,
89
+ stdin: "ignore",
90
+ stdout: "inherit",
91
+ stderr: "inherit",
92
+ }),
93
+ });
94
+ }
95
+ }
96
+ for (const pkg of plan.customPackages) {
97
+ jobs.push({
98
+ name: pkg.name,
99
+ result: exec.spawn("bun", ["run", "compile"], {
100
+ cwd: pkg.directory,
101
+ stdin: "ignore",
102
+ stdout: (line) => console.log(`${pkg.name}: ${line.replace(emoji, "")}`),
103
+ stderr: (line) => console.error(`${pkg.name}: ${line.replace(emoji, "")}`),
104
+ }),
105
+ });
106
+ }
107
+ const results = await Promise.allSettled(jobs.map((job) => job.result));
108
+ let failed = false;
109
+ for (const [index, result] of results.entries()) {
110
+ if (result.status === "rejected") {
111
+ console.error(`${jobs[index]!.name} compile failed:`, result.reason);
112
+ failed = true;
113
+ } else if (result.value.exitCode !== 0) {
114
+ console.error(`${jobs[index]!.name} compile exited with ${result.value.exitCode}`);
115
+ failed = true;
116
+ }
117
+ }
118
+ if (failed) process.exitCode = 1;
119
+ }
120
+
121
+ if (import.meta.main) await main();