@dbx-tools/projen 0.9.17 → 0.9.19

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
@@ -7,13 +7,15 @@ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workf
7
7
  import { projectReleaseBranch, type DBXToolsJavaScriptProject } from "./project-js.ts";
8
8
  import { applyTasks, taskScript } from "./project.ts";
9
9
  import {
10
+ RELEASE_SHA,
11
+ RELEASE_TAG,
10
12
  RELEASE_VERSION,
11
13
  releaseSourceSteps,
12
14
  type ReleaseSummaryProviderName,
13
15
  } from "./release-dispatch.ts";
14
16
  import { readWorkspaceVersion } from "./workspace-version.ts";
15
17
 
16
- const NODE_VERSION = "lts/*";
18
+ const NODE_VERSION = "24";
17
19
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
18
20
  const nodeReleaseProjects = new WeakSet<DBXToolsJavaScriptProject>();
19
21
  const releaseTagPrefixes = new WeakMap<DBXToolsJavaScriptProject, string>();
@@ -21,7 +23,7 @@ const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>(
21
23
  const independentPublicationJobs = new WeakMap<GithubWorkflow, Set<string>>();
22
24
 
23
25
  /** Independently recoverable portions of the release workflow. */
24
- export type ReleaseStage = "all" | "node" | "python" | "docs";
26
+ export type ReleaseStage = "all" | "node" | "python" | "cargo" | "docs";
25
27
 
26
28
  /** GitHub Pages configuration included in the unified release workflow. */
27
29
  export interface ReleaseDocsOptions {
@@ -101,47 +103,32 @@ export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
101
103
  return `${prefix}*`;
102
104
  }
103
105
 
104
- /** Run a release stage on default-branch pushes or when selected for manual recovery. */
105
- export function releaseStageCondition(stage: Exclude<ReleaseStage, "all">): string {
106
- return `\${{ github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == '${stage}' }}`;
106
+ /** Run a release stage on promotion or when selected for manual recovery. */
107
+ export function releaseStageCondition(
108
+ stage: Exclude<ReleaseStage, "all">,
109
+ prerequisites: readonly string[] = [],
110
+ ): string {
111
+ if (prerequisites.length === 0) {
112
+ return `\${{ github.event_name == 'release' || inputs.stage == 'all' || inputs.stage == '${stage}' }}`;
113
+ }
114
+ const prefix = prerequisites.length
115
+ ? `always() && ${prerequisites.map((condition) => `(${condition})`).join(" && ")} && `
116
+ : "";
117
+ return `\${{ ${prefix}(github.event_name == 'release' || inputs.stage == 'all' || inputs.stage == '${stage}') }}`;
107
118
  }
108
119
 
109
120
  /** Publish a selected stage unless a manual run remains in dry-run mode. */
110
- export function releasePublishCondition(stage: Exclude<ReleaseStage, "all">): string {
111
- return `\${{ github.event_name == 'push' || (inputs.dry_run == false && (inputs.stage == 'all' || inputs.stage == '${stage}')) }}`;
112
- }
113
-
114
- /** Download release artifacts from this run or a verified earlier run. */
115
- export function releaseArtifactSteps(options: {
116
- readonly currentName: string;
117
- readonly recoveredName: string;
118
- readonly pattern: string;
119
- readonly path: string;
120
- }): readonly JobStep[] {
121
- const shared = {
122
- pattern: options.pattern,
123
- path: options.path,
124
- "merge-multiple": true,
125
- };
126
- return [
127
- {
128
- name: options.currentName,
129
- if: "${{ inputs.source_run_id == '' }}",
130
- uses: "actions/download-artifact@v8",
131
- with: shared,
132
- },
133
- {
134
- name: options.recoveredName,
135
- if: "${{ inputs.source_run_id != '' }}",
136
- uses: "actions/download-artifact@v8",
137
- with: {
138
- ...shared,
139
- "run-id": "${{ inputs.source_run_id }}",
140
- "github-token": "${{ github.token }}",
141
- repository: "${{ github.repository }}",
142
- },
143
- },
144
- ];
121
+ export function releasePublishCondition(
122
+ stage: Exclude<ReleaseStage, "all">,
123
+ prerequisites: readonly string[] = [],
124
+ ): string {
125
+ if (prerequisites.length === 0) {
126
+ return `\${{ github.event_name == 'release' || (inputs.dry_run != true && (inputs.stage == 'all' || inputs.stage == '${stage}')) }}`;
127
+ }
128
+ const prefix = prerequisites.length
129
+ ? `always() && ${prerequisites.map((condition) => `(${condition})`).join(" && ")} && `
130
+ : "";
131
+ return `\${{ ${prefix}(github.event_name == 'release' || (inputs.dry_run != true && (inputs.stage == 'all' || inputs.stage == '${stage}'))) }}`;
145
132
  }
146
133
 
147
134
  /** Shared Bun, Node, cache, and install setup for Node release jobs. */
@@ -152,7 +139,11 @@ export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): reado
152
139
  {
153
140
  name: "Setup Node.js",
154
141
  uses: "actions/setup-node@v6",
155
- with: { "node-version": NODE_VERSION, "registry-url": NPM_REGISTRY_URL },
142
+ with: {
143
+ "node-version": NODE_VERSION,
144
+ "registry-url": NPM_REGISTRY_URL,
145
+ "package-manager-cache": false,
146
+ },
156
147
  },
157
148
  { name: "Install", run: "bun install" },
158
149
  bunCacheSaveStep(),
@@ -163,9 +154,7 @@ export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): reado
163
154
  export function npmPublishEnvironment(): Record<string, string> {
164
155
  return {
165
156
  NPM_CONFIG_PROVENANCE:
166
- "${{ (github.event_name == 'push' || inputs.dry_run == false) && 'true' || 'false' }}",
167
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
168
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
157
+ "${{ (github.event_name == 'release' || inputs.dry_run != true) && 'true' || 'false' }}",
169
158
  DRY_RUN:
170
159
  "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '--dry-run' || '' }}",
171
160
  };
@@ -174,7 +163,7 @@ export function npmPublishEnvironment(): Record<string, string> {
174
163
  function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
175
164
  return {
176
165
  runsOn: ["ubuntu-latest"],
177
- permissions: { actions: JobPermission.READ, contents: JobPermission.WRITE },
166
+ permissions: { contents: JobPermission.READ },
178
167
  outputs: {
179
168
  release_tag: { stepId: "release", outputName: "release_tag" },
180
169
  expected_sha: { stepId: "release", outputName: "expected_sha" },
@@ -185,8 +174,8 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
185
174
  name: "Checkout release source",
186
175
  uses: "actions/checkout@v6",
187
176
  with: {
188
- ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
189
- "fetch-depth": 2,
177
+ ref: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}",
178
+ "fetch-depth": 0,
190
179
  },
191
180
  },
192
181
  {
@@ -201,51 +190,32 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
201
190
  shell: "bash",
202
191
  env: {
203
192
  RELEASE_TAG:
204
- "${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || '' }}",
205
- EXPECTED_SHA:
206
- "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
207
- DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
193
+ "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}",
194
+ GH_TOKEN: "${{ github.token }}",
208
195
  },
209
196
  // prettier-ignore
210
197
  run: stringUtils.dedent(
211
198
  // ============================================================================
212
199
  /*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
200
+ case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac
201
+ RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"
202
+ bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
203
+ git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
204
+ git fetch --force origin "+refs/heads/${releaseBranch}:refs/remotes/origin/${releaseBranch}"
205
+ test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
206
+ RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"
207
+ test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
208
+ git merge-base --is-ancestor "$RELEASE_SHA" "origin/${releaseBranch}"
209
+ test "$(tr -d '\\r\\n' < VERSION)" = "$RELEASE_VERSION"
210
+ test "$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)" = "false"
211
+ rm -rf dist/release-candidate
212
+ mkdir -p dist/release-candidate
213
+ gh release download "$RELEASE_TAG" --dir dist/release-candidate
214
+ bun node_modules/@dbx-tools/projen/tasks/release-manifest.ts verify \
215
+ --directory dist/release-candidate \
216
+ --tag "$RELEASE_TAG" \
217
+ --sha "$RELEASE_SHA" \
218
+ --version "$RELEASE_VERSION"
249
219
  echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
250
220
  echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
251
221
  echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
@@ -253,31 +223,6 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
253
223
  // ============================================================================
254
224
  ),
255
225
  },
256
- {
257
- name: "Verify source artifact run",
258
- if: "${{ inputs.source_run_id != '' }}",
259
- uses: "actions/github-script@v8",
260
- env: {
261
- EXPECTED_SHA: "${{ steps.release.outputs.expected_sha }}",
262
- SOURCE_RUN_ID: "${{ inputs.source_run_id }}",
263
- },
264
- with: {
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
- ),
279
- },
280
- },
281
226
  ],
282
227
  };
283
228
  }
@@ -293,13 +238,27 @@ function nodePublishJob(project: DBXToolsJavaScriptProject): Job {
293
238
  steps: [
294
239
  ...nodeReleaseSetupSteps(project),
295
240
  {
296
- name: "Compile, package, and publish npm workspace",
297
- env: { RELEASE_VERSION, ...npmPublishEnvironment() },
241
+ name: "Download approved npm archives",
242
+ env: { GH_TOKEN: "${{ github.token }}", RELEASE_SHA, RELEASE_TAG, RELEASE_VERSION },
243
+ shell: "bash",
298
244
  run: [
299
- "chmod -R u+w . || true",
300
- 'bun node_modules/@dbx-tools/projen/tasks/publish.ts "$RELEASE_VERSION" $DRY_RUN',
245
+ "rm -rf dist/release-download dist/npm-release",
246
+ "mkdir -p dist/release-download",
247
+ 'gh release download "$RELEASE_TAG" --pattern release-manifest.json --pattern SHA256SUMS --pattern "*.tgz" --dir dist/release-download',
248
+ "bun node_modules/@dbx-tools/projen/tasks/release-manifest.ts verify \\",
249
+ " --directory dist/release-download \\",
250
+ ' --tag "$RELEASE_TAG" \\',
251
+ ' --sha "$RELEASE_SHA" \\',
252
+ ' --version "$RELEASE_VERSION" \\',
253
+ " --kind npm \\",
254
+ " --output dist/npm-release",
301
255
  ].join("\n"),
302
256
  },
257
+ {
258
+ name: "Publish approved npm archives",
259
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
260
+ run: 'bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory dist/npm-release --version "$RELEASE_VERSION" $DRY_RUN',
261
+ },
303
262
  ],
304
263
  };
305
264
  }
@@ -591,8 +550,7 @@ function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
591
550
  python_packages: { stepId: "plan", outputName: "python_packages" },
592
551
  rust_packages: { stepId: "plan", outputName: "rust_packages" },
593
552
  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" },
553
+ rust_targets: { stepId: "plan", outputName: "rust_targets" },
596
554
  },
597
555
  steps: [
598
556
  {
@@ -1020,7 +978,12 @@ export class DBXToolsRelease extends Component {
1020
978
  ),
1021
979
  receiveArgs: true,
1022
980
  description:
1023
- "Prepare, validate, locally publish, and automatically merge a release PR",
981
+ "Prepare, validate, locally publish, and upload a draft release candidate",
982
+ },
983
+ "release:assets": {
984
+ exec: taskScript(project, "release-candidate.ts"),
985
+ receiveArgs: true,
986
+ description: "Rebuild and upload a complete draft release candidate",
1024
987
  },
1025
988
  }
1026
989
  : {}),
@@ -1030,14 +993,18 @@ export class DBXToolsRelease extends Component {
1030
993
  const workflow = new GithubWorkflow(project.github, "release", {
1031
994
  fileName: "release.yml",
1032
995
  limitConcurrency: true,
1033
- concurrencyOptions: { group: "release", cancelInProgress: false },
996
+ concurrencyOptions: {
997
+ group:
998
+ "release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}",
999
+ cancelInProgress: false,
1000
+ },
1034
1001
  });
1035
1002
  const version = readWorkspaceVersion(project.outdir);
1036
1003
  workflow.runName =
1037
1004
  `release ${version} ` +
1038
- "${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
1005
+ "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}";
1039
1006
  workflow.on({
1040
- push: { branches: [releaseBranch], paths: ["VERSION"] },
1007
+ release: { types: ["published"] },
1041
1008
  workflowDispatch: {
1042
1009
  inputs: {
1043
1010
  release_tag: {
@@ -1045,24 +1012,13 @@ export class DBXToolsRelease extends Component {
1045
1012
  type: "string",
1046
1013
  required: true,
1047
1014
  },
1048
- expected_sha: {
1049
- description: "Commit the release tag must reference",
1050
- type: "string",
1051
- required: true,
1052
- },
1053
1015
  stage: {
1054
- description: "Release stage to build, validate, or recover",
1016
+ description: "Published release stage to validate or recover",
1055
1017
  type: "choice",
1056
- options: ["all", "node", "python", "docs"],
1018
+ options: ["all", "node", "python", "cargo", "docs"],
1057
1019
  default: "all",
1058
1020
  required: true,
1059
1021
  },
1060
- source_run_id: {
1061
- description: "Earlier release workflow run containing Rust artifacts",
1062
- type: "string",
1063
- default: "",
1064
- required: false,
1065
- },
1066
1022
  dry_run: {
1067
1023
  description: "Build and validate without publishing",
1068
1024
  type: "boolean",
@@ -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();
@@ -28,6 +28,15 @@ export interface NpmReleaseIdentity {
28
28
  readonly version: string;
29
29
  }
30
30
 
31
+ interface NpmArchiveManifest {
32
+ readonly dependencies?: Readonly<Record<string, string>>;
33
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
34
+ readonly name?: string;
35
+ readonly publishConfig?: { readonly access?: unknown };
36
+ readonly repository?: unknown;
37
+ readonly version?: string;
38
+ }
39
+
31
40
  function normalizedRepository(value: unknown): string | undefined {
32
41
  const url =
33
42
  typeof value === "string"
@@ -120,7 +129,7 @@ export async function publishedNpmRelease(
120
129
  };
121
130
  }
122
131
 
123
- export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
132
+ function readNpmArchiveManifest(path: string): NpmArchiveManifest {
124
133
  const result = exec.spawnSync("tar", ["-xOf", path, "package/package.json"], {
125
134
  cwd: process.cwd(),
126
135
  stdout: "capture",
@@ -131,12 +140,11 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
131
140
  if (result.exitCode !== 0 || !result.stdout) {
132
141
  throw new Error(`Cannot read npm package manifest from ${path}: ${result.stderr}`);
133
142
  }
134
- const manifest = JSON.parse(result.stdout) as {
135
- name?: string;
136
- publishConfig?: { access?: unknown };
137
- repository?: unknown;
138
- version?: string;
139
- };
143
+ return JSON.parse(result.stdout) as NpmArchiveManifest;
144
+ }
145
+
146
+ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
147
+ const manifest = readNpmArchiveManifest(path);
140
148
  if (!manifest.name || !manifest.version) {
141
149
  throw new Error(`npm archive has no package name or version: ${path}`);
142
150
  }
@@ -154,6 +162,48 @@ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
154
162
  };
155
163
  }
156
164
 
165
+ function orderNpmArchives(archives: readonly string[]): string[] {
166
+ const byName = new Map(
167
+ archives.map((archive) => {
168
+ const manifest = readNpmArchiveManifest(archive);
169
+ if (!manifest.name) throw new Error(`npm archive has no package name: ${archive}`);
170
+ return [manifest.name, { archive, manifest }] as const;
171
+ }),
172
+ );
173
+ if (byName.size !== archives.length) throw new Error("Release contains duplicate npm packages");
174
+ const remaining = new Map(
175
+ [...byName].map(([name, value]) => [
176
+ name,
177
+ new Set(
178
+ [
179
+ ...Object.keys(value.manifest.dependencies ?? {}),
180
+ ...Object.keys(value.manifest.optionalDependencies ?? {}),
181
+ ].filter((dependency) => byName.has(dependency)),
182
+ ),
183
+ ]),
184
+ );
185
+ const ordered: string[] = [];
186
+ while (remaining.size > 0) {
187
+ const ready = [...remaining]
188
+ .filter(([, dependencies]) => dependencies.size === 0)
189
+ .map(([name]) => name)
190
+ .sort();
191
+ if (ready.length === 0) {
192
+ throw new Error(
193
+ `Cyclic npm release dependencies: ${[...remaining.keys()].sort().join(", ")}`,
194
+ );
195
+ }
196
+ for (const name of ready) {
197
+ ordered.push(byName.get(name)!.archive);
198
+ remaining.delete(name);
199
+ }
200
+ for (const dependencies of remaining.values()) {
201
+ for (const name of ready) dependencies.delete(name);
202
+ }
203
+ }
204
+ return ordered;
205
+ }
206
+
157
207
  /** Hash paths, executable bits, symlink targets, and bytes while ignoring tar metadata. */
158
208
  export function npmArchiveContentDigest(path: string): string {
159
209
  const temp = mkdtempSync(join(tmpdir(), "projen-npm-content-"));
@@ -219,10 +269,12 @@ export async function publishNpmArchives(options: {
219
269
  readonly version?: string;
220
270
  }): Promise<void> {
221
271
  const directory = resolve(options.directory);
222
- const archives = readdirSync(directory)
223
- .filter((file) => file.endsWith(".tgz"))
224
- .sort()
225
- .map((file) => join(directory, file));
272
+ const archives = orderNpmArchives(
273
+ readdirSync(directory)
274
+ .filter((file) => file.endsWith(".tgz"))
275
+ .sort()
276
+ .map((file) => join(directory, file)),
277
+ );
226
278
  if (archives.length === 0) throw new Error(`No npm archives found in ${directory}`);
227
279
 
228
280
  for (const archive of archives) {