@dbx-tools/projen 0.9.18 → 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,48 +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 condition?: string;
121
- }): readonly JobStep[] {
122
- const shared = {
123
- pattern: options.pattern,
124
- path: options.path,
125
- "merge-multiple": true,
126
- };
127
- return [
128
- {
129
- name: options.currentName,
130
- if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id == '' }}`,
131
- uses: "actions/download-artifact@v8",
132
- with: shared,
133
- },
134
- {
135
- name: options.recoveredName,
136
- if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id != '' }}`,
137
- uses: "actions/download-artifact@v8",
138
- with: {
139
- ...shared,
140
- "run-id": "${{ inputs.source_run_id }}",
141
- "github-token": "${{ github.token }}",
142
- repository: "${{ github.repository }}",
143
- },
144
- },
145
- ];
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}'))) }}`;
146
132
  }
147
133
 
148
134
  /** Shared Bun, Node, cache, and install setup for Node release jobs. */
@@ -153,7 +139,11 @@ export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): reado
153
139
  {
154
140
  name: "Setup Node.js",
155
141
  uses: "actions/setup-node@v6",
156
- 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
+ },
157
147
  },
158
148
  { name: "Install", run: "bun install" },
159
149
  bunCacheSaveStep(),
@@ -164,9 +154,7 @@ export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): reado
164
154
  export function npmPublishEnvironment(): Record<string, string> {
165
155
  return {
166
156
  NPM_CONFIG_PROVENANCE:
167
- "${{ (github.event_name == 'push' || inputs.dry_run == false) && 'true' || 'false' }}",
168
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
169
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
157
+ "${{ (github.event_name == 'release' || inputs.dry_run != true) && 'true' || 'false' }}",
170
158
  DRY_RUN:
171
159
  "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '--dry-run' || '' }}",
172
160
  };
@@ -175,19 +163,18 @@ export function npmPublishEnvironment(): Record<string, string> {
175
163
  function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
176
164
  return {
177
165
  runsOn: ["ubuntu-latest"],
178
- permissions: { actions: JobPermission.READ, contents: JobPermission.WRITE },
166
+ permissions: { contents: JobPermission.READ },
179
167
  outputs: {
180
168
  release_tag: { stepId: "release", outputName: "release_tag" },
181
169
  expected_sha: { stepId: "release", outputName: "expected_sha" },
182
170
  release_version: { stepId: "release", outputName: "release_version" },
183
- build_mode: { stepId: "release", outputName: "build_mode" },
184
171
  },
185
172
  steps: [
186
173
  {
187
174
  name: "Checkout release source",
188
175
  uses: "actions/checkout@v6",
189
176
  with: {
190
- ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
177
+ ref: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}",
191
178
  "fetch-depth": 0,
192
179
  },
193
180
  },
@@ -203,90 +190,39 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
203
190
  shell: "bash",
204
191
  env: {
205
192
  RELEASE_TAG:
206
- "${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || '' }}",
207
- EXPECTED_SHA:
208
- "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
209
- DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
193
+ "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}",
210
194
  GH_TOKEN: "${{ github.token }}",
211
195
  },
212
196
  // prettier-ignore
213
197
  run: stringUtils.dedent(
214
198
  // ============================================================================
215
199
  /*bash*/`
216
- if [ "$GITHUB_EVENT_NAME" = "push" ]; then
217
- test "$GITHUB_REF_TYPE" = "branch"
218
- test "$GITHUB_REF_NAME" = "${releaseBranch}"
219
- RELEASE_SHA="$GITHUB_SHA"
220
- test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
221
- RELEASE_VERSION="$(tr -d '\\r\\n' < VERSION)"
222
- bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
223
- PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d '\\r\\n')"
224
- test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"
225
- RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"
226
- if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then
227
- git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
228
- test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
229
- test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"
230
- else
231
- git fetch --force --tags origin
232
- bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION" --prefix ${JSON.stringify(tagPrefix)} --assert-next
233
- git config user.name "github-actions[bot]"
234
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
235
- git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"
236
- git push origin "refs/tags/$RELEASE_TAG"
237
- fi
238
- else
239
- case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac
240
- RELEASE_VERSION="\${RELEASE_TAG#${tagPrefix}}"
241
- bun node_modules/@dbx-tools/projen/tasks/release-version.ts --version "$RELEASE_VERSION"
242
- git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
243
- test "$(git cat-file -t "$RELEASE_TAG")" = "tag"
244
- RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"
245
- test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
246
- test "$RELEASE_SHA" = "$EXPECTED_SHA"
247
- if [ -n "\${{ inputs.source_run_id }}" ]; then
248
- case "\${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac
249
- case "\${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac
250
- fi
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
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"
257
219
  echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
258
220
  echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
259
221
  echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
260
- echo "build_mode=$BUILD_MODE" >> "$GITHUB_OUTPUT"
261
222
  `
262
223
  // ============================================================================
263
224
  ),
264
225
  },
265
- {
266
- name: "Verify source artifact run",
267
- if: "${{ inputs.source_run_id != '' }}",
268
- uses: "actions/github-script@v8",
269
- env: {
270
- EXPECTED_SHA: "${{ steps.release.outputs.expected_sha }}",
271
- SOURCE_RUN_ID: "${{ inputs.source_run_id }}",
272
- },
273
- with: {
274
- // prettier-ignore
275
- script: stringUtils.dedent(
276
- // ============================================================================
277
- /*js*/`
278
- const run = await github.rest.actions.getWorkflowRun({
279
- owner: context.repo.owner,
280
- repo: context.repo.repo,
281
- run_id: Number(process.env.SOURCE_RUN_ID),
282
- });
283
- if (run.data.path !== ".github/workflows/release.yml") core.setFailed("Source run is not release.yml");
284
- if (run.data.head_sha !== process.env.EXPECTED_SHA) core.setFailed("Source run commit does not match the release tag");
285
- `
286
- // ============================================================================
287
- ),
288
- },
289
- },
290
226
  ],
291
227
  };
292
228
  }
@@ -302,13 +238,27 @@ function nodePublishJob(project: DBXToolsJavaScriptProject): Job {
302
238
  steps: [
303
239
  ...nodeReleaseSetupSteps(project),
304
240
  {
305
- name: "Compile, package, and publish npm workspace",
306
- 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",
307
244
  run: [
308
- "chmod -R u+w . || true",
309
- '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",
310
255
  ].join("\n"),
311
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
+ },
312
262
  ],
313
263
  };
314
264
  }
@@ -1028,12 +978,12 @@ export class DBXToolsRelease extends Component {
1028
978
  ),
1029
979
  receiveArgs: true,
1030
980
  description:
1031
- "Prepare, validate, locally publish, and automatically merge a release PR",
981
+ "Prepare, validate, locally publish, and upload a draft release candidate",
1032
982
  },
1033
983
  "release:assets": {
1034
- exec: taskScript(project, "release-assets.ts"),
984
+ exec: taskScript(project, "release-candidate.ts"),
1035
985
  receiveArgs: true,
1036
- description: "Build and upload native assets for an existing GitHub Release",
986
+ description: "Rebuild and upload a complete draft release candidate",
1037
987
  },
1038
988
  }
1039
989
  : {}),
@@ -1043,14 +993,18 @@ export class DBXToolsRelease extends Component {
1043
993
  const workflow = new GithubWorkflow(project.github, "release", {
1044
994
  fileName: "release.yml",
1045
995
  limitConcurrency: true,
1046
- 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
+ },
1047
1001
  });
1048
1002
  const version = readWorkspaceVersion(project.outdir);
1049
1003
  workflow.runName =
1050
1004
  `release ${version} ` +
1051
- "${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
1005
+ "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}";
1052
1006
  workflow.on({
1053
- push: { branches: [releaseBranch], paths: ["VERSION"] },
1007
+ release: { types: ["published"] },
1054
1008
  workflowDispatch: {
1055
1009
  inputs: {
1056
1010
  release_tag: {
@@ -1058,24 +1012,13 @@ export class DBXToolsRelease extends Component {
1058
1012
  type: "string",
1059
1013
  required: true,
1060
1014
  },
1061
- expected_sha: {
1062
- description: "Commit the release tag must reference",
1063
- type: "string",
1064
- required: true,
1065
- },
1066
1015
  stage: {
1067
- description: "Release stage to build, validate, or recover",
1016
+ description: "Published release stage to validate or recover",
1068
1017
  type: "choice",
1069
- options: ["all", "node", "python", "docs"],
1018
+ options: ["all", "node", "python", "cargo", "docs"],
1070
1019
  default: "all",
1071
1020
  required: true,
1072
1021
  },
1073
- source_run_id: {
1074
- description: "Earlier release workflow run containing Rust artifacts",
1075
- type: "string",
1076
- default: "",
1077
- required: false,
1078
- },
1079
1022
  dry_run: {
1080
1023
  description: "Build and validate without publishing",
1081
1024
  type: "boolean",
@@ -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) {
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  chmodSync,
4
4
  existsSync,
5
+ mkdirSync,
5
6
  mkdtempSync,
6
7
  readFileSync,
7
8
  readdirSync,
@@ -29,10 +30,19 @@ interface PythonProjectFile {
29
30
  }
30
31
 
31
32
  export interface StampPythonProjectsOptions {
33
+ readonly excludeUniFFI?: boolean;
32
34
  readonly rewriteDependencies?: boolean;
33
35
  readonly versions?: ReadonlyMap<string, string>;
34
36
  }
35
37
 
38
+ /** Python wheel and source archives accepted by Twine and package indexes. */
39
+ export function pythonDistributionPaths(directory: string): string[] {
40
+ return readdirSync(directory)
41
+ .filter((file) => file.endsWith(".whl") || file.endsWith(".tar.gz"))
42
+ .sort()
43
+ .map((file) => join(directory, file));
44
+ }
45
+
36
46
  export interface RestorePythonProjects {
37
47
  (): void;
38
48
  readonly paths: readonly string[];
@@ -63,7 +73,10 @@ export function stampPythonProjects(
63
73
  };
64
74
  });
65
75
  const projects = allProjects.filter(
66
- (project) => !project.private && (!options.versions || options.versions.has(project.directory)),
76
+ (project) =>
77
+ !project.private &&
78
+ (!options.excludeUniFFI || !project.uniffi) &&
79
+ (!options.versions || options.versions.has(project.directory)),
67
80
  );
68
81
  if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
69
82
 
@@ -110,12 +123,65 @@ export function publishPythonProjects(options: {
110
123
  readonly version: string;
111
124
  readonly plan?: ReleasePlan;
112
125
  }): void {
113
- const root = resolve(options.root);
114
126
  const output = mkdtempSync(join(tmpdir(), "projen-python-publish-"));
127
+ try {
128
+ buildPythonProjects({ ...options, output });
129
+ exec.spawnSync(
130
+ "uvx",
131
+ [
132
+ "--from",
133
+ "devpi-client",
134
+ "devpi",
135
+ "upload",
136
+ "--index",
137
+ options.publishUrl,
138
+ "--from-dir",
139
+ ...(options.dryRun ? ["--dry-run"] : []),
140
+ output,
141
+ ],
142
+ {
143
+ cwd: process.cwd(),
144
+ env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl },
145
+ stdout: "inherit",
146
+ stderr: "inherit",
147
+ stdin: "ignore",
148
+ check: true,
149
+ },
150
+ );
151
+ } finally {
152
+ rmSync(output, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ /** Build the exact Python release distributions without publishing them. */
157
+ export function buildPythonProjects(options: {
158
+ readonly allowEmpty?: boolean;
159
+ readonly excludeUniFFI?: boolean;
160
+ readonly output: string;
161
+ readonly root: string;
162
+ readonly version: string;
163
+ readonly plan?: ReleasePlan;
164
+ }): void {
165
+ const root = resolve(options.root);
166
+ const output = resolve(options.output);
167
+ rmSync(output, { recursive: true, force: true });
168
+ const standardProjects = options.excludeUniFFI
169
+ ? readdirSync(root, { withFileTypes: true })
170
+ .filter((entry) => entry.isDirectory())
171
+ .map((entry) => resolve(root, entry.name, "pyproject.toml"))
172
+ .filter(existsSync)
173
+ .map((path) => pythonProjectInfo(readFileSync(path, "utf8"), { parse, stringify }))
174
+ .filter((project) => !project.private && !project.uniffi)
175
+ : undefined;
176
+ if (standardProjects?.length === 0 && options.allowEmpty) {
177
+ mkdirSync(output, { recursive: true });
178
+ return;
179
+ }
115
180
  const planned = new Map(
116
181
  options.plan?.pythonPackages.map((pkg) => [pkg.path.replace(/^.*\//, ""), pkg.version]) ?? [],
117
182
  );
118
183
  const stamp = stampPythonProjects(root, options.version, {
184
+ excludeUniFFI: options.excludeUniFFI,
119
185
  ...(options.plan ? { versions: planned } : {}),
120
186
  });
121
187
  try {
@@ -130,6 +196,16 @@ export function publishPythonProjects(options: {
130
196
  check: true,
131
197
  });
132
198
  }
199
+ } else if (options.excludeUniFFI) {
200
+ for (const project of standardProjects ?? []) {
201
+ exec.spawnSync("uv", ["build", "--package", project.name, "--out-dir", output], {
202
+ cwd: process.cwd(),
203
+ stdout: "inherit",
204
+ stderr: "inherit",
205
+ stdin: "ignore",
206
+ check: true,
207
+ });
208
+ }
133
209
  } else {
134
210
  exec.spawnSync("uv", ["build", "--all-packages", "--out-dir", output], {
135
211
  cwd: process.cwd(),
@@ -139,31 +215,19 @@ export function publishPythonProjects(options: {
139
215
  check: true,
140
216
  });
141
217
  }
142
- exec.spawnSync(
143
- "uvx",
144
- [
145
- "--from",
146
- "devpi-client",
147
- "devpi",
148
- "upload",
149
- "--index",
150
- options.publishUrl,
151
- "--from-dir",
152
- ...(options.dryRun ? ["--dry-run"] : []),
153
- output,
154
- ],
155
- {
156
- cwd: process.cwd(),
157
- env: { ...process.env, UV_DEFAULT_INDEX: options.indexUrl },
158
- stdout: "inherit",
159
- stderr: "inherit",
160
- stdin: "ignore",
161
- check: true,
162
- },
163
- );
218
+ const distributions = pythonDistributionPaths(output);
219
+ if (distributions.length === 0) {
220
+ throw new Error(`No Python distributions found in ${output}`);
221
+ }
222
+ exec.spawnSync("uvx", ["twine", "check", ...distributions], {
223
+ cwd: process.cwd(),
224
+ stdout: "inherit",
225
+ stderr: "inherit",
226
+ stdin: "ignore",
227
+ check: true,
228
+ });
164
229
  } finally {
165
230
  stamp();
166
- rmSync(output, { recursive: true, force: true });
167
231
  }
168
232
  }
169
233
 
@@ -175,6 +239,7 @@ if (import.meta.main) {
175
239
  .requiredOption("--publish-url <url>", "devpi writable index URL")
176
240
  .option("--root <path>", "Python workspace package root", "packages/py")
177
241
  .option("--plan <path>", "affected release plan")
242
+ .option("--output <path>", "Build distributions into a directory without publishing")
178
243
  .option("--dry-run", "build and inspect distributions without uploading")
179
244
  .action(
180
245
  (
@@ -185,6 +250,7 @@ if (import.meta.main) {
185
250
  publishUrl: string;
186
251
  root: string;
187
252
  plan?: string;
253
+ output?: string;
188
254
  },
189
255
  ) => {
190
256
  const plan = options.plan
@@ -192,7 +258,16 @@ if (import.meta.main) {
192
258
  : undefined;
193
259
  const fallbackVersion = version ?? plan?.pythonPackages[0]?.version;
194
260
  if (!fallbackVersion) throw new Error("Python publication requires a version or plan");
195
- publishPythonProjects({ ...options, version: fallbackVersion, plan });
261
+ if (options.output) {
262
+ buildPythonProjects({
263
+ output: options.output,
264
+ root: options.root,
265
+ version: fallbackVersion,
266
+ plan,
267
+ });
268
+ } else {
269
+ publishPythonProjects({ ...options, version: fallbackVersion, plan });
270
+ }
196
271
  },
197
272
  );
198
273
  await program.parseAsync();