@dbx-tools/projen 0.9.153 → 0.9.154

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/README.md CHANGED
@@ -601,11 +601,14 @@ site. Install validation prerequisites through `releaseSetupSteps`.
601
601
  Publication jobs download the resulting artifacts rather than rebuilding the
602
602
  workspace. Each Python package retains its own publishing environment and waits
603
603
  for its package dependencies to publish. The npm job uses the bundled archive
604
- publisher without a checkout or workspace install. Pages deploys the already
605
- built site after registry publication succeeds. Local npm and Python registry publication remains available
606
- through the direct local publication tooling. Those local publishers run
607
- concurrently; a failure or release termination stops every live publisher before
608
- the command returns.
604
+ publisher without a checkout or workspace install. After all selected public
605
+ publishers finish, one verifier downloads the complete release artifact set and
606
+ polls only unresolved packages concurrently. It validates npm tarball contents
607
+ and PyPI distribution hashes before Pages deploys the already built site. Local
608
+ npm and Python registry publication remains available through the direct local
609
+ publication tooling and does not wait for registry propagation. Those local
610
+ publishers run concurrently; a failure or release termination stops every live
611
+ publisher before the command returns.
609
612
 
610
613
  The default release publishes npm, Python, and docs, then publishes to
611
614
  configured local registries. Use `bun run release --help` or the generated
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.153";
6
+ export const PACKAGE_VERSION = "0.9.154";
7
7
  export * as appkitWorkspace from "./src/appkit-workspace.ts";
8
8
  export * as barrels from "./src/barrels.ts";
9
9
  export * as bunApp from "./src/bun-app.ts";
@@ -69,7 +69,7 @@ export { PYTHON_NODE_RUNTIME_DISTRIBUTION, PYTHON_NODE_RUNTIME_MODULE, PYTHON_NO
69
69
  export type { PythonNodeRuntimeOptions } from "./src/python-node-runtime.ts";
70
70
  export { DBXToolsRelease } from "./src/release.ts";
71
71
  export type { ReleaseDocsOptions, DBXToolsReleaseOptions } from "./src/release.ts";
72
- export { RELEASE_VERSION, RELEASE_WORKFLOW_FILE } from "./src/release-context.ts";
72
+ export { RELEASE_VERSION, RELEASE_WORKFLOW_FILE, PUBLIC_NPM_REGISTRY_URL, PUBLIC_PYPI_REGISTRY_URL } from "./src/release-context.ts";
73
73
  export { RELEASE_PUBLISH_TARGETS, RELEASE_INSTALL_MODES, ReleaseStepSelectionSchema } from "./src/release-options.ts";
74
74
  export type { ReleasePublishTarget, ReleaseInstallMode, ReleaseStepSelection, ReleaseSelectionOptions } from "./src/release-options.ts";
75
75
  export { SHARED_CORE_BINDING_EXPORTS } from "./src/shared-core-usage.ts";
package/package.json CHANGED
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/auth": "^0.9.153",
34
- "@dbx-tools/cli-args": "^0.9.153",
35
- "@dbx-tools/core": "^0.9.153",
36
- "@dbx-tools/path": "^0.9.153",
37
- "@dbx-tools/shared-core": "^0.9.153",
33
+ "@dbx-tools/auth": "^0.9.154",
34
+ "@dbx-tools/cli-args": "^0.9.154",
35
+ "@dbx-tools/core": "^0.9.154",
36
+ "@dbx-tools/path": "^0.9.154",
37
+ "@dbx-tools/shared-core": "^0.9.154",
38
38
  "commander": "^15.0.0",
39
39
  "concurrently": "^10.0.3",
40
40
  "constructs": "^10.6.0",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "main": "index.ts",
58
58
  "license": "Apache-2.0",
59
- "version": "0.9.153",
59
+ "version": "0.9.154",
60
60
  "types": "index.ts",
61
61
  "type": "module",
62
62
  "exports": {
package/src/project-py.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  PythonNodeRuntime,
20
20
  } from "./python-node-runtime.ts";
21
21
  import {
22
+ refreshPublicationVerification,
22
23
  refreshReleaseDocsDependencies,
23
24
  releaseCondition,
24
25
  releaseBuildSteps,
@@ -705,6 +706,7 @@ export class DBXToolsPythonWorkspace extends Component {
705
706
  ],
706
707
  });
707
708
  }
709
+ refreshPublicationVerification(project);
708
710
  refreshReleaseDocsDependencies(project);
709
711
  }
710
712
 
@@ -3,3 +3,7 @@
3
3
  export const RELEASE_VERSION = "${{ needs.build-release.outputs.release_version }}";
4
4
  /** Generated GitHub Actions workflow triggered by release tags. */
5
5
  export const RELEASE_WORKFLOW_FILE = "release.yml";
6
+ /** Public npm registry queried after npm publication completes. */
7
+ export const PUBLIC_NPM_REGISTRY_URL = "https://registry.npmjs.org";
8
+ /** Public PyPI JSON API queried after Python publication completes. */
9
+ export const PUBLIC_PYPI_REGISTRY_URL = "https://pypi.org/pypi";
package/src/release.ts CHANGED
@@ -16,12 +16,17 @@ import { GithubWorkflow } from "projen/lib/github";
16
16
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
17
17
  import { BUN_VERSION } from "./bun-workflow.ts";
18
18
  import { projectReleaseBranch, taskCommand, type DBXToolsJavaScriptProject } from "./project-js.ts";
19
- import { RELEASE_VERSION, RELEASE_WORKFLOW_FILE } from "./release-context.ts";
19
+ import {
20
+ PUBLIC_NPM_REGISTRY_URL,
21
+ RELEASE_VERSION,
22
+ RELEASE_WORKFLOW_FILE,
23
+ } from "./release-context.ts";
20
24
  import type { ReleaseStepSelection } from "./release-options.ts";
21
25
 
22
26
  const NODE_VERSION = "24";
23
27
  const NPM_VERSION = "11.4.2";
24
- const NPM_REGISTRY_URL = "https://registry.npmjs.org";
28
+ const PUBLICATION_VERIFIER_ARTIFACT = "release-publication-verifier";
29
+ const PUBLICATION_VERIFIER_JOB = "verify-publications";
25
30
  const releaseTagPrefixes = new WeakMap<DBXToolsJavaScriptProject, string>();
26
31
  const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>();
27
32
 
@@ -95,7 +100,7 @@ export function nodeReleaseSetupSteps(): readonly JobStep[] {
95
100
  uses: github.ActionRefs.ACTIONS_SETUP_NODE,
96
101
  with: {
97
102
  "node-version": NODE_VERSION,
98
- "registry-url": NPM_REGISTRY_URL,
103
+ "registry-url": PUBLIC_NPM_REGISTRY_URL,
99
104
  "package-manager-cache": false,
100
105
  },
101
106
  },
@@ -114,19 +119,14 @@ export function uvSetupStep(): JobStep {
114
119
  function refreshDocsRegistryDependencies(workflow: GithubWorkflow): void {
115
120
  const docs = workflow.getJob("deploy-docs") as Job | undefined;
116
121
  if (!docs) return;
117
- const registryJobs = Object.keys(workflow.jobs).filter(
118
- (name) => name === "publish-node" || name.startsWith("publish-pypi-"),
119
- );
120
- if (registryJobs.length > 0) {
122
+ if (workflow.getJob(PUBLICATION_VERIFIER_JOB)) {
121
123
  workflow.updateJob("deploy-docs", {
122
124
  ...docs,
123
- needs: ["build-release", ...registryJobs],
125
+ needs: ["build-release", PUBLICATION_VERIFIER_JOB],
124
126
  if: releaseCondition([
125
127
  "needs['build-release'].result == 'success'",
126
128
  "needs['build-release'].outputs.docs == 'true'",
127
- ...registryJobs.map(
128
- (name) => `needs['${name}'].result == 'success' || needs['${name}'].result == 'skipped'`,
129
- ),
129
+ `needs['${PUBLICATION_VERIFIER_JOB}'].result == 'success' || needs['${PUBLICATION_VERIFIER_JOB}'].result == 'skipped'`,
130
130
  ]),
131
131
  });
132
132
  }
@@ -140,6 +140,107 @@ export function refreshReleaseDocsDependencies(project: DBXToolsJavaScriptProjec
140
140
  }
141
141
  }
142
142
 
143
+ function publicationRegistryJobs(workflow: GithubWorkflow): string[] {
144
+ return Object.keys(workflow.jobs).filter(
145
+ (name) => name === "publish-node" || name.startsWith("publish-pypi-"),
146
+ );
147
+ }
148
+
149
+ /** Keep one public availability barrier behind every configured registry publisher. */
150
+ export function refreshPublicationVerification(project: DBXToolsJavaScriptProject): void {
151
+ const workflow = releaseWorkflows.get(project);
152
+ if (!workflow) return;
153
+ const registryJobs = publicationRegistryJobs(workflow);
154
+ if (registryJobs.length === 0) return;
155
+ const hasNpm = registryJobs.includes("publish-node");
156
+ const hasPypi = registryJobs.some((name) => name.startsWith("publish-pypi-"));
157
+ const selected = [
158
+ ...(hasNpm ? ["needs['build-release'].outputs.npm == 'true'"] : []),
159
+ ...(hasPypi ? ["needs['build-release'].outputs.pypi == 'true'"] : []),
160
+ ].join(" || ");
161
+ const prerequisites = registryJobs.map((name) => {
162
+ const output = name === "publish-node" ? "npm" : "pypi";
163
+ return `needs['build-release'].outputs.${output} != 'true' || needs['${name}'].result == 'success'`;
164
+ });
165
+ const job: Job = {
166
+ if: releaseCondition([
167
+ "needs['build-release'].result == 'success'",
168
+ selected,
169
+ ...prerequisites,
170
+ ]),
171
+ needs: ["build-release", ...registryJobs],
172
+ runsOn: ["ubuntu-latest"],
173
+ permissions: { contents: JobPermission.READ },
174
+ timeoutMinutes: 15,
175
+ steps: [
176
+ {
177
+ name: "Download publication verifier",
178
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
179
+ with: { name: PUBLICATION_VERIFIER_ARTIFACT, path: ".release/verifier" },
180
+ },
181
+ ...(hasNpm
182
+ ? [
183
+ {
184
+ name: "Download npm archives",
185
+ if: "${{ needs.build-release.outputs.npm == 'true' }}",
186
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
187
+ with: { name: "release-npm", path: ".release/npm" },
188
+ },
189
+ ]
190
+ : []),
191
+ ...(hasPypi
192
+ ? [
193
+ {
194
+ name: "Download Python distributions",
195
+ if: "${{ needs.build-release.outputs.pypi == 'true' }}",
196
+ uses: github.ActionRefs.ACTIONS_DOWNLOAD_ARTIFACT,
197
+ with: {
198
+ pattern: "release-python-*",
199
+ path: ".release/python",
200
+ "merge-multiple": true,
201
+ },
202
+ },
203
+ ]
204
+ : []),
205
+ {
206
+ name: "Setup Bun",
207
+ uses: github.ActionRefs.OVEN_SH_SETUP_BUN,
208
+ with: { "bun-version": BUN_VERSION },
209
+ },
210
+ {
211
+ name: "Wait for public package availability",
212
+ shell: "bash",
213
+ env: {
214
+ RELEASE_VERSION,
215
+ VERIFY_NPM: hasNpm ? "${{ needs.build-release.outputs.npm }}" : "false",
216
+ VERIFY_PYPI: hasPypi ? "${{ needs.build-release.outputs.pypi }}" : "false",
217
+ },
218
+ // prettier-ignore
219
+ run: stringUtils.dedent(
220
+ // ============================================================================
221
+ /*bash*/`
222
+ args=(--version "$RELEASE_VERSION")
223
+ if [[ "$VERIFY_NPM" == "true" ]]; then
224
+ args+=(--npm-directory .release/npm)
225
+ fi
226
+ if [[ "$VERIFY_PYPI" == "true" ]]; then
227
+ args+=(--pypi-directory .release/python)
228
+ fi
229
+ bun .release/verifier/verify-publications.mjs "\${args[@]}"
230
+ `
231
+ // ============================================================================
232
+ ),
233
+ },
234
+ ],
235
+ };
236
+ if (workflow.getJob(PUBLICATION_VERIFIER_JOB)) {
237
+ workflow.updateJob(PUBLICATION_VERIFIER_JOB, job);
238
+ } else {
239
+ workflow.addJob(PUBLICATION_VERIFIER_JOB, job);
240
+ }
241
+ refreshDocsRegistryDependencies(workflow);
242
+ }
243
+
143
244
  function releaseBuildJob(
144
245
  project: DBXToolsJavaScriptProject,
145
246
  tagPrefix: string,
@@ -229,6 +330,24 @@ function releaseBuildJob(
229
330
  with: { name: "release-npm", path: ".release/npm", "if-no-files-found": "error" },
230
331
  },
231
332
  ])),
333
+ {
334
+ name: "Build publication verifier",
335
+ if: "${{ success() && (steps.release.outputs.npm == 'true' || steps.release.outputs.pypi == 'true') }}",
336
+ run: [
337
+ "mkdir -p .release/verifier",
338
+ "bun build node_modules/@dbx-tools/projen/tasks/verify-publications.ts --target=bun --outfile=.release/verifier/verify-publications.mjs",
339
+ ].join("\n"),
340
+ },
341
+ {
342
+ name: "Upload publication verifier",
343
+ if: "${{ success() && (steps.release.outputs.npm == 'true' || steps.release.outputs.pypi == 'true') }}",
344
+ uses: github.ActionRefs.ACTIONS_UPLOAD_ARTIFACT,
345
+ with: {
346
+ name: PUBLICATION_VERIFIER_ARTIFACT,
347
+ path: ".release/verifier/verify-publications.mjs",
348
+ "if-no-files-found": "error",
349
+ },
350
+ },
232
351
  ...(options.docs
233
352
  ? releaseBuildSteps("docs", [
234
353
  ...options.docs.prepareSteps,
@@ -356,6 +475,7 @@ export class DBXToolsRelease extends Component {
356
475
  if (options.nodeRelease !== false) {
357
476
  workflow.addJob("publish-node", nodePublishJob());
358
477
  }
478
+ refreshPublicationVerification(project);
359
479
  if (options.docs) {
360
480
  addDocsJobs(workflow);
361
481
  }
@@ -20,11 +20,11 @@ import { z } from "zod";
20
20
  import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
21
21
  import { TaskConcurrencyOptionSchema, TaskDryRunOptionSchema } from "./options.ts";
22
22
  import { runTaskCommandAsync } from "../src/_task-command.ts";
23
+ import { PUBLIC_NPM_REGISTRY_URL } from "../src/release-context.ts";
23
24
  import { materializeWorkspaceManifest } from "../src/workspace-manifest.ts";
24
25
 
25
26
  export { materializeWorkspaceManifest } from "../src/workspace-manifest.ts";
26
27
 
27
- const DEFAULT_REGISTRY = "https://registry.npmjs.org";
28
28
  const DEFAULT_PUBLISH_CONCURRENCY = 4;
29
29
  const logger = log.logger("projen:publish-npm");
30
30
 
@@ -132,10 +132,12 @@ export function npmReleaseMatches(
132
132
  export async function publishedNpmRelease(
133
133
  name: string,
134
134
  version: string,
135
- registry = process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY,
135
+ registry = process.env.NPM_CONFIG_REGISTRY ?? PUBLIC_NPM_REGISTRY_URL,
136
+ signal?: AbortSignal,
136
137
  ): Promise<NpmReleaseIdentity | undefined> {
137
138
  const response = await fetch(registryUrl(registry, name, version), {
138
139
  headers: { accept: "application/json" },
140
+ signal,
139
141
  });
140
142
  if (response.status === 404) return undefined;
141
143
  if (!response.ok) {
@@ -152,7 +154,7 @@ export async function publishedNpmRelease(
152
154
  }
153
155
  let contentDigest: string | undefined;
154
156
  if (metadata.dist?.tarball) {
155
- const archive = await fetch(metadata.dist.tarball);
157
+ const archive = await fetch(metadata.dist.tarball, { signal });
156
158
  if (!archive.ok) {
157
159
  throw new Error(`npm tarball lookup failed for ${name}@${version}: ${archive.status}`);
158
160
  }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Wait for one release batch to become available from public package registries. */
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
7
+ import { z } from "zod";
8
+ import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
9
+ import { npmReleaseMatches, publishedNpmRelease, readNpmArchiveIdentity } from "./publish-npm.ts";
10
+ import { PUBLIC_NPM_REGISTRY_URL, PUBLIC_PYPI_REGISTRY_URL } from "../src/release-context.ts";
11
+
12
+ const DEFAULT_CONCURRENCY = 8;
13
+ const DEFAULT_INTERVAL_SECONDS = 5;
14
+ const DEFAULT_TIMEOUT_SECONDS = 10 * 60;
15
+ const logger = log.logger("projen:verify-publications");
16
+
17
+ const PypiReleaseSchema = z
18
+ .object({
19
+ urls: z
20
+ .array(
21
+ z
22
+ .object({
23
+ filename: z.string().describe("Published distribution filename"),
24
+ digests: z
25
+ .object({
26
+ sha256: z.string().describe("Published distribution SHA-256 digest"),
27
+ })
28
+ .describe("Published distribution digests"),
29
+ })
30
+ .describe("One published Python distribution"),
31
+ )
32
+ .describe("Published Python distributions"),
33
+ })
34
+ .describe("PyPI release metadata");
35
+
36
+ interface PypiReleaseIdentity {
37
+ readonly files: ReadonlyMap<string, string>;
38
+ readonly name: string;
39
+ readonly version: string;
40
+ }
41
+
42
+ /** One public artifact group checked during the shared availability barrier. */
43
+ export interface PublicationProbe {
44
+ /** Human-readable package identity used in progress and timeout messages. */
45
+ readonly label: string;
46
+ /** Return true only when the exact expected public artifact is available. */
47
+ available(signal: AbortSignal): boolean | PromiseLike<boolean>;
48
+ }
49
+
50
+ /** Timing and concurrency controls for the shared publication barrier. */
51
+ export interface PublicationWaitOptions {
52
+ readonly concurrency?: number;
53
+ readonly intervalMs?: number;
54
+ readonly timeoutMs?: number;
55
+ }
56
+
57
+ /** Poll every unresolved publication concurrently and stop after one shared timeout. */
58
+ export async function waitForPublications(
59
+ probes: readonly PublicationProbe[],
60
+ options: PublicationWaitOptions = {},
61
+ ): Promise<void> {
62
+ if (probes.length === 0) throw new Error("No public publications were provided");
63
+ const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
64
+ const errors = new Map<string, Error>();
65
+ let pending = [...probes];
66
+ let previousCount: number | undefined;
67
+ try {
68
+ for await (const unavailable of asyncUtils.poll<PublicationProbe[]>(
69
+ async ({ signal }) => {
70
+ const results = await asyncUtils.mapConcurrent(
71
+ pending,
72
+ async (probe) => {
73
+ try {
74
+ const available = await probe.available(signal);
75
+ if (available) errors.delete(probe.label);
76
+ return { available, probe };
77
+ } catch (error) {
78
+ errors.set(probe.label, errorUtils.toError(error));
79
+ return { available: false, probe };
80
+ }
81
+ },
82
+ { concurrency },
83
+ );
84
+ return results.filter(({ available }) => !available).map(({ probe }) => probe);
85
+ },
86
+ {
87
+ intervalMs: options.intervalMs ?? DEFAULT_INTERVAL_SECONDS * 1_000,
88
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_SECONDS * 1_000,
89
+ predicate: (unavailable) => unavailable.length > 0,
90
+ },
91
+ )) {
92
+ pending = unavailable;
93
+ if (pending.length > 0 && pending.length !== previousCount) {
94
+ logger.info(
95
+ `waiting for ${pending.length} public publication${pending.length === 1 ? "" : "s"}`,
96
+ );
97
+ }
98
+ previousCount = pending.length;
99
+ }
100
+ } catch (error) {
101
+ if (pending.length === 0) throw error;
102
+ const details = pending
103
+ .map((probe) => {
104
+ const failure = errors.get(probe.label);
105
+ return failure ? `${probe.label}: ${failure.message}` : probe.label;
106
+ })
107
+ .join("\n");
108
+ throw new Error(`Publications did not become available:\n${details}`, { cause: error });
109
+ }
110
+ logger.info(`verified ${probes.length} public publication${probes.length === 1 ? "" : "s"}`);
111
+ }
112
+
113
+ function npmPublicationProbes(directory: string, version: string): PublicationProbe[] {
114
+ return readdirSync(directory)
115
+ .filter((file) => file.endsWith(".tgz"))
116
+ .sort()
117
+ .map((file) => {
118
+ const local = readNpmArchiveIdentity(join(directory, file));
119
+ if (local.version !== version) {
120
+ throw new Error(`${local.name} archive carries ${local.version}, expected ${version}`);
121
+ }
122
+ return {
123
+ label: `${local.name}@${local.version}`,
124
+ available: async (signal: AbortSignal) =>
125
+ npmReleaseMatches(
126
+ local,
127
+ await publishedNpmRelease(local.name, local.version, PUBLIC_NPM_REGISTRY_URL, signal),
128
+ ),
129
+ };
130
+ });
131
+ }
132
+
133
+ function distributionFiles(directory: string): string[] {
134
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
135
+ const path = join(directory, entry.name);
136
+ if (entry.isDirectory()) return distributionFiles(path);
137
+ return entry.name.endsWith(".whl") || entry.name.endsWith(".tar.gz") ? [path] : [];
138
+ });
139
+ }
140
+
141
+ function pythonDistributionName(file: string, version: string): string {
142
+ const name = basename(file);
143
+ const sourceSuffix = `-${version}.tar.gz`;
144
+ if (name.endsWith(sourceSuffix)) {
145
+ return name.slice(0, -sourceSuffix.length).replaceAll("_", "-").toLowerCase();
146
+ }
147
+ if (name.endsWith(".whl")) {
148
+ const marker = `-${version}-`;
149
+ const markerIndex = name.lastIndexOf(marker);
150
+ if (markerIndex > 0) {
151
+ return name.slice(0, markerIndex).replaceAll("_", "-").toLowerCase();
152
+ }
153
+ }
154
+ throw new Error(`Python distribution ${name} does not carry release version ${version}`);
155
+ }
156
+
157
+ function pypiReleaseIdentities(directory: string, version: string): PypiReleaseIdentity[] {
158
+ const releases = new Map<string, Map<string, string>>();
159
+ for (const path of distributionFiles(directory)) {
160
+ const name = pythonDistributionName(path, version);
161
+ const files = releases.get(name) ?? new Map<string, string>();
162
+ const filename = basename(path);
163
+ files.set(filename, createHash("sha256").update(readFileSync(path)).digest("hex"));
164
+ releases.set(name, files);
165
+ }
166
+ return [...releases]
167
+ .sort(([left], [right]) => left.localeCompare(right))
168
+ .map(([name, files]) => ({ files, name, version }));
169
+ }
170
+
171
+ function pypiPublicationProbes(directory: string, version: string): PublicationProbe[] {
172
+ return pypiReleaseIdentities(directory, version).map((local) => ({
173
+ label: `${local.name}==${local.version}`,
174
+ available: async (signal: AbortSignal) => {
175
+ const response = await fetch(
176
+ `${PUBLIC_PYPI_REGISTRY_URL}/${encodeURIComponent(local.name)}/${encodeURIComponent(local.version)}/json`,
177
+ {
178
+ headers: { accept: "application/json" },
179
+ signal,
180
+ },
181
+ );
182
+ if (response.status === 404) return false;
183
+ if (!response.ok) {
184
+ throw new Error(
185
+ `PyPI lookup failed for ${local.name}==${local.version}: ${response.status}`,
186
+ );
187
+ }
188
+ const published = PypiReleaseSchema.parse(await response.json());
189
+ const publishedFiles = new Map(
190
+ published.urls.map(({ filename, digests }) => [filename, digests.sha256]),
191
+ );
192
+ for (const [filename, digest] of local.files) {
193
+ const publishedDigest = publishedFiles.get(filename);
194
+ if (!publishedDigest) return false;
195
+ if (publishedDigest !== digest) {
196
+ throw new Error(
197
+ `PyPI content does not match ${local.name}==${local.version} ${filename}`,
198
+ );
199
+ }
200
+ }
201
+ return true;
202
+ },
203
+ }));
204
+ }
205
+
206
+ export const VerifyPublicationsOptionsSchema = z
207
+ .object({
208
+ version: z.string().trim().min(1).describe("Expected public release version"),
209
+ npmDirectory: z
210
+ .string()
211
+ .trim()
212
+ .min(1)
213
+ .optional()
214
+ .describe("Directory containing released npm archives"),
215
+ pypiDirectory: z
216
+ .string()
217
+ .trim()
218
+ .min(1)
219
+ .optional()
220
+ .describe("Directory containing released Python distributions"),
221
+ concurrency: z.coerce
222
+ .number()
223
+ .int()
224
+ .positive()
225
+ .default(DEFAULT_CONCURRENCY)
226
+ .describe("Maximum concurrent public registry checks"),
227
+ intervalSeconds: z.coerce
228
+ .number()
229
+ .positive()
230
+ .default(DEFAULT_INTERVAL_SECONDS)
231
+ .describe("Delay between shared publication checks"),
232
+ timeoutSeconds: z.coerce
233
+ .number()
234
+ .positive()
235
+ .default(DEFAULT_TIMEOUT_SECONDS)
236
+ .describe("Maximum time to wait for the complete publication batch"),
237
+ })
238
+ .refine((options) => options.npmDirectory || options.pypiDirectory, {
239
+ message: "At least one publication directory is required",
240
+ })
241
+ .describe("Options for the public package availability barrier");
242
+
243
+ /** Parse expected release artifacts and wait for their public registry records. */
244
+ export async function main(args: readonly string[] = process.argv.slice(2)): Promise<void> {
245
+ const options = await taskOptions(
246
+ taskCommand(
247
+ import.meta.url,
248
+ "Wait for released packages to become publicly available",
249
+ VerifyPublicationsOptionsSchema,
250
+ ),
251
+ VerifyPublicationsOptionsSchema,
252
+ args,
253
+ );
254
+ const probes = [
255
+ ...(options.npmDirectory
256
+ ? npmPublicationProbes(resolve(options.npmDirectory), options.version)
257
+ : []),
258
+ ...(options.pypiDirectory
259
+ ? pypiPublicationProbes(resolve(options.pypiDirectory), options.version)
260
+ : []),
261
+ ];
262
+ await waitForPublications(probes, {
263
+ concurrency: options.concurrency,
264
+ intervalMs: options.intervalSeconds * 1_000,
265
+ timeoutMs: options.timeoutSeconds * 1_000,
266
+ });
267
+ }
268
+
269
+ await runTaskMain(import.meta, main);