@dbx-tools/projen 0.9.152 → 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.
@@ -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);