@dbx-tools/projen 0.6.177 → 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.
- package/README.md +47 -35
- package/index.ts +3 -3
- package/package.json +4 -4
- package/src/project-js.ts +7 -16
- package/src/project-py.ts +47 -249
- package/src/project-rs.ts +210 -244
- package/src/release-dispatch.ts +9 -11
- package/src/release.ts +313 -625
- package/tasks/publish-npm.ts +197 -0
- package/tasks/publish.ts +38 -1
- package/tasks/stamp-python.ts +13 -0
|
@@ -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 {
|
|
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();
|