@dbx-tools/projen 0.6.176 → 0.6.179

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,197 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Idempotent npm archive publication for release recovery. */
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { exec } from "@dbx-tools/core";
7
+ import { log } from "@dbx-tools/shared-core";
8
+ import { Command } from "commander";
9
+
10
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
11
+ const logger = log.logger("dbx-tools:publish-npm");
12
+
13
+ export interface NpmReleaseIdentity {
14
+ readonly integrity?: string;
15
+ readonly name: string;
16
+ readonly repository?: unknown;
17
+ readonly version: string;
18
+ }
19
+
20
+ function normalizedRepository(value: unknown): string | undefined {
21
+ const url =
22
+ typeof value === "string"
23
+ ? value
24
+ : value && typeof value === "object" && "url" in value
25
+ ? String(value.url)
26
+ : undefined;
27
+ return url
28
+ ?.replace(/^git\+/, "")
29
+ .replace(/\.git$/, "")
30
+ .replace(/\/$/, "");
31
+ }
32
+
33
+ function registryUrl(registry: string, name: string, version: string): string {
34
+ return `${registry.replace(/\/$/, "")}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
35
+ }
36
+
37
+ export function npmReleaseMatches(
38
+ local: NpmReleaseIdentity,
39
+ published: NpmReleaseIdentity | undefined,
40
+ ): boolean {
41
+ if (!published) return false;
42
+ if (published.name !== local.name || published.version !== local.version) {
43
+ throw new Error(`Published npm identity does not match ${local.name}@${local.version}`);
44
+ }
45
+ const localRepository = normalizedRepository(local.repository);
46
+ const publishedRepository = normalizedRepository(published.repository);
47
+ if (localRepository && publishedRepository && localRepository !== publishedRepository) {
48
+ throw new Error(`Published npm repository does not match ${local.name}@${local.version}`);
49
+ }
50
+ if (local.integrity && published.integrity !== local.integrity) {
51
+ throw new Error(`Published npm integrity does not match ${local.name}@${local.version}`);
52
+ }
53
+ return true;
54
+ }
55
+
56
+ export async function publishedNpmRelease(
57
+ name: string,
58
+ version: string,
59
+ registry = process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY,
60
+ ): Promise<NpmReleaseIdentity | undefined> {
61
+ const response = await fetch(registryUrl(registry, name, version), {
62
+ headers: { accept: "application/json" },
63
+ });
64
+ if (response.status === 404) return undefined;
65
+ if (!response.ok) {
66
+ throw new Error(`npm registry lookup failed for ${name}@${version}: ${response.status}`);
67
+ }
68
+ const metadata = (await response.json()) as {
69
+ dist?: { integrity?: string };
70
+ name?: string;
71
+ repository?: unknown;
72
+ version?: string;
73
+ };
74
+ if (!metadata.name || !metadata.version) {
75
+ throw new Error(`npm registry returned an incomplete identity for ${name}@${version}`);
76
+ }
77
+ return {
78
+ integrity: metadata.dist?.integrity,
79
+ name: metadata.name,
80
+ repository: metadata.repository,
81
+ version: metadata.version,
82
+ };
83
+ }
84
+
85
+ export function readNpmArchiveIdentity(path: string): NpmReleaseIdentity {
86
+ const result = exec.spawnSync("tar", ["-xOf", path, "package/package.json"], {
87
+ cwd: process.cwd(),
88
+ stdout: "capture",
89
+ stderr: "capture",
90
+ stdin: "ignore",
91
+ check: false,
92
+ });
93
+ if (result.exitCode !== 0 || !result.stdout) {
94
+ throw new Error(`Cannot read npm package manifest from ${path}: ${result.stderr}`);
95
+ }
96
+ const manifest = JSON.parse(result.stdout) as {
97
+ name?: string;
98
+ repository?: unknown;
99
+ version?: string;
100
+ };
101
+ if (!manifest.name || !manifest.version) {
102
+ throw new Error(`npm archive has no package name or version: ${path}`);
103
+ }
104
+ return {
105
+ integrity: `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`,
106
+ name: manifest.name,
107
+ repository: manifest.repository,
108
+ version: manifest.version,
109
+ };
110
+ }
111
+
112
+ export function packNpmPackage(
113
+ directory: string,
114
+ destination: string,
115
+ path = process.env.PATH ?? "",
116
+ ): string {
117
+ const executable = process.versions.bun ? process.execPath : "bun";
118
+ exec.spawnSync(
119
+ executable,
120
+ ["pm", "pack", "--destination", destination, "--ignore-scripts", "--quiet"],
121
+ {
122
+ cwd: directory,
123
+ env: { ...process.env, PATH: path },
124
+ stdout: "inherit",
125
+ stderr: "inherit",
126
+ stdin: "ignore",
127
+ check: true,
128
+ },
129
+ );
130
+ const archives = readdirSync(destination).filter((file) => file.endsWith(".tgz"));
131
+ if (archives.length !== 1) {
132
+ throw new Error(`Expected one packed npm archive, found ${archives.length}`);
133
+ }
134
+ return join(destination, archives[0]);
135
+ }
136
+
137
+ export async function publishNpmArchives(options: {
138
+ readonly directory: string;
139
+ readonly dryRun?: boolean;
140
+ readonly registry?: string;
141
+ readonly version: string;
142
+ }): Promise<void> {
143
+ const directory = resolve(options.directory);
144
+ const archives = readdirSync(directory)
145
+ .filter((file) => file.endsWith(".tgz"))
146
+ .sort()
147
+ .map((file) => join(directory, file));
148
+ if (archives.length === 0) throw new Error(`No npm archives found in ${directory}`);
149
+
150
+ for (const archive of archives) {
151
+ const local = readNpmArchiveIdentity(archive);
152
+ if (local.version !== options.version) {
153
+ throw new Error(
154
+ `npm archive ${archive} carries ${local.version}, expected ${options.version}`,
155
+ );
156
+ }
157
+ if (!options.dryRun) {
158
+ const published = await publishedNpmRelease(local.name, local.version, options.registry);
159
+ if (npmReleaseMatches(local, published)) {
160
+ logger.info(`skip published ${local.name}@${local.version}`);
161
+ continue;
162
+ }
163
+ }
164
+ exec.spawnSync(
165
+ "npm",
166
+ [
167
+ "publish",
168
+ archive,
169
+ "--access",
170
+ "public",
171
+ ...(options.registry ? ["--registry", options.registry] : []),
172
+ ...(options.dryRun ? ["--dry-run"] : []),
173
+ ],
174
+ {
175
+ cwd: process.cwd(),
176
+ stdout: "inherit",
177
+ stderr: "inherit",
178
+ stdin: "ignore",
179
+ check: true,
180
+ },
181
+ );
182
+ }
183
+ }
184
+
185
+ if (import.meta.main) {
186
+ const program = new Command();
187
+ program
188
+ .requiredOption("--directory <path>", "Directory containing npm archives")
189
+ .requiredOption("--version <version>", "Exact npm release version")
190
+ .option("--registry <url>", "npm registry URL")
191
+ .option("--dry-run", "Validate archives without publishing")
192
+ .action(
193
+ (options: { directory: string; dryRun?: boolean; registry?: string; version: string }) =>
194
+ publishNpmArchives(options),
195
+ );
196
+ await program.parseAsync();
197
+ }
package/tasks/publish.ts CHANGED
@@ -36,6 +36,9 @@
36
36
  * The later `bun publish --ignore-scripts` calls therefore pack the already
37
37
  * compiled `lib/` trees instead of serially repeating each member's
38
38
  * `prepack`. Packages retain their `prepack` task for standalone publishes.
39
+ * - **release recovery** packs each exact version before upload and compares
40
+ * its integrity and repository identity with registry metadata. A matching
41
+ * immutable version is skipped, while any mismatch fails the retry.
39
42
  *
40
43
  * `--dry-run` forwards to `bun publish`: it packs + validates
41
44
  * but uploads nothing, so the `release` workflow is testable end-to-end via a
@@ -56,12 +59,27 @@
56
59
  * content, which already equals the release version, so the worktree is never
57
60
  * left regressed.
58
61
  */
59
- import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
62
+ import {
63
+ chmodSync,
64
+ existsSync,
65
+ mkdtempSync,
66
+ readFileSync,
67
+ rmSync,
68
+ statSync,
69
+ writeFileSync,
70
+ } from "node:fs";
71
+ import { tmpdir } from "node:os";
60
72
  import { dirname, join, resolve } from "node:path";
61
73
  import { exec } from "@dbx-tools/core";
62
74
  import { log } from "@dbx-tools/shared-core";
63
75
  import ts from "typescript";
64
76
  import { parse } from "yaml";
77
+ import {
78
+ npmReleaseMatches,
79
+ packNpmPackage,
80
+ publishedNpmRelease,
81
+ readNpmArchiveIdentity,
82
+ } from "./publish-npm.ts";
65
83
 
66
84
  const logger = log.logger("dbx-tools:publish");
67
85
 
@@ -375,6 +393,25 @@ logger.info(
375
393
  `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
376
394
  );
377
395
  await runConcurrent(publishable, concurrency, async ({ dir, name }) => {
396
+ if (!dryRun) {
397
+ const packed = mkdtempSync(join(tmpdir(), "dbx-tools-npm-release-"));
398
+ try {
399
+ const archive = packNpmPackage(dir, packed, path);
400
+ const local = readNpmArchiveIdentity(archive);
401
+ if (local.name !== name || local.version !== version) {
402
+ throw new Error(
403
+ `Packed npm identity ${local.name}@${local.version} does not match ${name}@${version}`,
404
+ );
405
+ }
406
+ const published = await publishedNpmRelease(local.name, local.version, registry);
407
+ if (npmReleaseMatches(local, published)) {
408
+ logger.info(`skip published ${name} @ ${version}`);
409
+ return;
410
+ }
411
+ } finally {
412
+ rmSync(packed, { recursive: true, force: true });
413
+ }
414
+ }
378
415
  logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${version}`);
379
416
  await runAsync(dir, "bun", ["publish", ...publishArgs], path);
380
417
  });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env -S bun
2
+ import { Command } from "commander";
3
+ import { stampPythonProjects } from "./publish-python.ts";
4
+
5
+ const program = new Command();
6
+ program
7
+ .argument("<version>", "Python package version")
8
+ .option("--root <path>", "Python workspace package root", "packages/py")
9
+ .action((version: string, options: { root: string }) => {
10
+ stampPythonProjects(options.root, version);
11
+ });
12
+
13
+ await program.parseAsync();