@dbx-tools/projen 0.6.194 → 0.6.196
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 +58 -42
- package/index.ts +2 -2
- package/package.json +4 -4
- package/src/pnpm-workspace.ts +4 -2
- package/src/project-js.ts +43 -12
- package/src/project-py.ts +7 -6
- package/src/project-rs.ts +99 -130
- package/src/release.ts +72 -22
- package/src/workspace-version.ts +57 -7
- package/tasks/bump.ts +19 -444
- package/tasks/clean.ts +1 -1
- package/tasks/local-publish.ts +166 -0
- package/tasks/publish-uniffi-local.ts +30 -8
- package/tasks/publish.ts +22 -59
- package/tasks/release-pr.ts +259 -0
- package/tasks/version-check.ts +85 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Local registry preflight shared by release preparation. */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { exec, project } from "@dbx-tools/core";
|
|
7
|
+
import { log, net } from "@dbx-tools/shared-core";
|
|
8
|
+
import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
|
|
9
|
+
import { readDbxToolsConfig } from "../src/packages.ts";
|
|
10
|
+
|
|
11
|
+
const logger = log.logger("projen:local-publish");
|
|
12
|
+
|
|
13
|
+
/** Options for publishing one release candidate to configured loopback registries. */
|
|
14
|
+
export interface LocalPublishOptions {
|
|
15
|
+
readonly root: string;
|
|
16
|
+
readonly version: string;
|
|
17
|
+
readonly localRegistry: string;
|
|
18
|
+
readonly localPypi: string;
|
|
19
|
+
readonly pythonRoot: string;
|
|
20
|
+
readonly localCargo: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveLocalRegistry(value: string): string | undefined {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
|
|
26
|
+
if (trimmed.toLowerCase() === "auto") {
|
|
27
|
+
const registry = project.npmRegistry();
|
|
28
|
+
return registry && net.isLoopbackHost(registry) ? registry.href : undefined;
|
|
29
|
+
}
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function localCargoRegistry(): string | undefined {
|
|
34
|
+
if (process.env.LOCAL_CARGO_REGISTRY) return process.env.LOCAL_CARGO_REGISTRY;
|
|
35
|
+
const config = join(homedir(), ".cargo", "config.toml");
|
|
36
|
+
if (!existsSync(config)) return undefined;
|
|
37
|
+
const source = readFileSync(config, "utf8");
|
|
38
|
+
const sections = [
|
|
39
|
+
...source.matchAll(/^\[registries\.([^\]]+)\]\s*\n([\s\S]*?)(?=^\[|(?![\s\S]))/gm),
|
|
40
|
+
];
|
|
41
|
+
for (const section of sections) {
|
|
42
|
+
const index = section[2]?.match(/^\s*index\s*=\s*["']([^"']+)["']/m)?.[1];
|
|
43
|
+
if (index && net.isLoopbackHost(index.replace(/^sparse\+/, ""))) return section[1];
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Publish the exact release candidate to local npm, PyPI, and Cargo mirrors. */
|
|
49
|
+
export async function publishLocalRelease(options: LocalPublishOptions): Promise<void> {
|
|
50
|
+
const localRegistry = resolveLocalRegistry(options.localRegistry);
|
|
51
|
+
const activeIndexes = activePythonIndexes();
|
|
52
|
+
const localPypi = resolveLocalPypi(options.localPypi, activeIndexes);
|
|
53
|
+
const pythonRoot = resolve(options.root, options.pythonRoot);
|
|
54
|
+
const localPublishes: Promise<unknown>[] = [];
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
options.localPypi.toLowerCase() === "auto" &&
|
|
58
|
+
activeIndexes.some((index) => net.isLoopbackHost(index)) &&
|
|
59
|
+
!localPypi
|
|
60
|
+
) {
|
|
61
|
+
logger.info(
|
|
62
|
+
`skipped local Python publish: no active index (${activeIndexes.join(", ")}) is a devpi +simple index`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (localRegistry) {
|
|
67
|
+
const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
|
|
68
|
+
logger.info(`publishing ${options.version} to local registry ${localRegistry}`);
|
|
69
|
+
localPublishes.push(
|
|
70
|
+
exec
|
|
71
|
+
.spawn(process.execPath, [publishScript, options.version, "--registry", localRegistry], {
|
|
72
|
+
cwd: options.root,
|
|
73
|
+
stdout: "inherit",
|
|
74
|
+
stderr: "inherit",
|
|
75
|
+
stdin: "ignore",
|
|
76
|
+
check: true,
|
|
77
|
+
})
|
|
78
|
+
.then(() => logger.success(`published ${options.version} to ${localRegistry}`)),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (localPypi && existsSync(pythonRoot)) {
|
|
83
|
+
const publishPythonScript = fileURLToPath(new URL("./publish-python.ts", import.meta.url));
|
|
84
|
+
logger.info(`publishing Python ${options.version} to local devpi ${localPypi.publishUrl}`);
|
|
85
|
+
localPublishes.push(
|
|
86
|
+
exec
|
|
87
|
+
.spawn(
|
|
88
|
+
process.execPath,
|
|
89
|
+
[
|
|
90
|
+
publishPythonScript,
|
|
91
|
+
options.version,
|
|
92
|
+
"--root",
|
|
93
|
+
pythonRoot,
|
|
94
|
+
"--index-url",
|
|
95
|
+
localPypi.indexUrl,
|
|
96
|
+
"--publish-url",
|
|
97
|
+
localPypi.publishUrl,
|
|
98
|
+
],
|
|
99
|
+
{
|
|
100
|
+
cwd: options.root,
|
|
101
|
+
stdout: "inherit",
|
|
102
|
+
stderr: "inherit",
|
|
103
|
+
stdin: "ignore",
|
|
104
|
+
check: true,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
.then(() =>
|
|
108
|
+
logger.success(`published Python ${options.version} to ${localPypi.publishUrl}`),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await Promise.all(localPublishes);
|
|
114
|
+
if (localRegistry || localPypi) {
|
|
115
|
+
const publishUniFFIScript = fileURLToPath(
|
|
116
|
+
new URL("./publish-uniffi-local.ts", import.meta.url),
|
|
117
|
+
);
|
|
118
|
+
await exec.spawn(
|
|
119
|
+
process.execPath,
|
|
120
|
+
[
|
|
121
|
+
publishUniFFIScript,
|
|
122
|
+
"--version",
|
|
123
|
+
options.version,
|
|
124
|
+
...(localRegistry ? ["--registry", localRegistry] : []),
|
|
125
|
+
...(localPypi ? ["--pypi-publish-url", localPypi.publishUrl] : []),
|
|
126
|
+
],
|
|
127
|
+
{
|
|
128
|
+
cwd: options.root,
|
|
129
|
+
stdout: "inherit",
|
|
130
|
+
stderr: "inherit",
|
|
131
|
+
stdin: "ignore",
|
|
132
|
+
check: true,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
logger.success(`published host-native bindings for ${options.version}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rust = readDbxToolsConfig(options.root)?.rust;
|
|
139
|
+
const hasRustCrates = Boolean(
|
|
140
|
+
rust &&
|
|
141
|
+
typeof rust === "object" &&
|
|
142
|
+
!Array.isArray(rust) &&
|
|
143
|
+
Array.isArray((rust as { crates?: unknown }).crates) &&
|
|
144
|
+
(rust as { crates: unknown[] }).crates.length > 0,
|
|
145
|
+
);
|
|
146
|
+
if (options.localCargo && hasRustCrates) {
|
|
147
|
+
const cargoRegistry = localCargoRegistry();
|
|
148
|
+
if (cargoRegistry) {
|
|
149
|
+
const publishUniFFIScript = fileURLToPath(
|
|
150
|
+
new URL("./publish-uniffi-local.ts", import.meta.url),
|
|
151
|
+
);
|
|
152
|
+
await exec.spawn(
|
|
153
|
+
process.execPath,
|
|
154
|
+
[publishUniFFIScript, "--version", options.version, "--cargo-registry", cargoRegistry],
|
|
155
|
+
{
|
|
156
|
+
cwd: options.root,
|
|
157
|
+
stdout: "inherit",
|
|
158
|
+
stderr: "inherit",
|
|
159
|
+
stdin: "ignore",
|
|
160
|
+
check: true,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
logger.success(`published Rust ${options.version} to ${cargoRegistry}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -5,9 +5,12 @@ import { arch, platform } from "node:os";
|
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
+
import { log } from "@dbx-tools/shared-core";
|
|
8
9
|
import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
|
|
9
10
|
import type { RustBindingMapping, RustWorkspaceMapping } from "../src/project-rs.ts";
|
|
10
11
|
|
|
12
|
+
const logger = log.logger("projen:publish-uniffi-local");
|
|
13
|
+
|
|
11
14
|
const parsed = parseArgs({
|
|
12
15
|
options: {
|
|
13
16
|
version: { type: "string" },
|
|
@@ -85,7 +88,16 @@ function artifacts(directory: string, suffix: string): string[] {
|
|
|
85
88
|
.map((name) => join(directory, name));
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
function
|
|
91
|
+
function cargoVersionExists(crateName: string, version: string, registry: string): boolean {
|
|
92
|
+
return (
|
|
93
|
+
spawnSync("cargo", ["info", `${crateName}@${version}`, "--registry", registry], {
|
|
94
|
+
cwd: repoRoot,
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
}).status === 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function publishCargo(config: RustWorkspaceMapping, registry: string, version: string): void {
|
|
89
101
|
const manifests = config.crates
|
|
90
102
|
.map((crate) => resolve(repoRoot, crate, "Cargo.toml"))
|
|
91
103
|
.filter((manifest) => !/^publish = false$/m.test(readFileSync(manifest, "utf8")));
|
|
@@ -115,6 +127,10 @@ function publishCargo(config: RustWorkspaceMapping, registry: string): void {
|
|
|
115
127
|
}
|
|
116
128
|
for (const crateName of crateNames) {
|
|
117
129
|
if (!crateName) continue;
|
|
130
|
+
if (cargoVersionExists(crateName, version, registry)) {
|
|
131
|
+
logger.info(`skip published ${crateName} @ ${version}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
118
134
|
run("cargo", [
|
|
119
135
|
"publish",
|
|
120
136
|
"--package",
|
|
@@ -180,12 +196,18 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
|
|
|
180
196
|
]);
|
|
181
197
|
|
|
182
198
|
if (includeNode) {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
const publishNpmScript = resolve(dirname(fileURLToPath(import.meta.url)), "publish-npm.ts");
|
|
200
|
+
for (const directory of [join(output, "npm"), join(output, "npm-facade")]) {
|
|
201
|
+
if (artifacts(directory, ".tgz").length === 0) continue;
|
|
202
|
+
run(process.execPath, [
|
|
203
|
+
publishNpmScript,
|
|
204
|
+
"--directory",
|
|
205
|
+
directory,
|
|
206
|
+
"--version",
|
|
207
|
+
version,
|
|
208
|
+
"--registry",
|
|
209
|
+
registry!,
|
|
210
|
+
]);
|
|
189
211
|
}
|
|
190
212
|
}
|
|
191
213
|
if (includePython) {
|
|
@@ -215,5 +237,5 @@ if (config?.bindings.length) {
|
|
|
215
237
|
}
|
|
216
238
|
const cargoRegistry = parsed.values["cargo-registry"];
|
|
217
239
|
if (cargoRegistry && config?.crates.length) {
|
|
218
|
-
publishCargo(config, cargoRegistry);
|
|
240
|
+
publishCargo(config, cargoRegistry, version);
|
|
219
241
|
}
|
package/tasks/publish.ts
CHANGED
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun
|
|
2
2
|
/**
|
|
3
3
|
* `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]`
|
|
4
|
-
* -
|
|
4
|
+
* - verify every workspace member and the Bun lock carry the release version,
|
|
5
5
|
* then publish each package owned by the standard Node release with `bun publish`.
|
|
6
6
|
*
|
|
7
7
|
* Bun has no `pnpm -r publish`, so this loop is the recursive-publish stand-in.
|
|
8
8
|
* It leans on native bun for everything bun already does:
|
|
9
9
|
*
|
|
10
|
-
* - **version
|
|
11
|
-
*
|
|
12
|
-
* dir's manifest, no git side effects; `bun pm version` is for bumping and
|
|
13
|
-
* errors on an unchanged version, so `pkg set` is the right tool for an exact
|
|
14
|
-
* release value);
|
|
10
|
+
* - **version validation** fails when any member differs from the reviewed
|
|
11
|
+
* `VERSION`; publication never repairs committed release metadata;
|
|
15
12
|
* - **`workspace:` / `catalog:` rewriting** is NOT done here - `bun publish`
|
|
16
13
|
* strips both protocols in the PACKED tarball, resolving `workspace:*` to the
|
|
17
14
|
* sibling's version and `catalog:` to the root catalog entry. (Verified: a
|
|
18
15
|
* packed manifest shows `"@scope/x": "<version>"` and the real catalog range,
|
|
19
16
|
* while the on-disk manifest keeps the protocols.) Setting each member's
|
|
20
17
|
* version first is the only prerequisite, so a sibling resolves the release
|
|
21
|
-
* version
|
|
22
|
-
*
|
|
23
|
-
* manifest and Bun's workspace lock already carry the version, both this
|
|
24
|
-
* stamp and the lockfile refresh are skipped;
|
|
18
|
+
* version. When Bun's workspace lock already carries the version, its
|
|
19
|
+
* refresh is skipped;
|
|
25
20
|
* - **`publishConfig` substitution** (compiled `lib/` entry points) is done
|
|
26
21
|
* HERE, by {@link applyPublishConfig}, NOT by bun: unlike pnpm/npm, `bun
|
|
27
22
|
* publish`/`bun pm pack` do NOT fold `publishConfig`'s `main`/`types`/`bin`/
|
|
@@ -46,14 +41,12 @@
|
|
|
46
41
|
* non-default registry (a local verdaccio); `--exclude <dir>` (repeatable,
|
|
47
42
|
* repo-relative) skips a member owned by another publication flow.
|
|
48
43
|
*
|
|
49
|
-
* `--no-restore` keeps the edits on disk instead of
|
|
50
|
-
*
|
|
51
|
-
* since its whole job is to stamp the workspace for a `bun publish` that runs
|
|
52
|
-
* afterwards from another directory.
|
|
44
|
+
* `--no-restore` keeps the transient publishConfig edits on disk instead of
|
|
45
|
+
* undoing them at exit.
|
|
53
46
|
*
|
|
54
47
|
* The disk manifests carry the workspace `VERSION` (projen owns them, read-only);
|
|
55
48
|
* this unlocks each only long enough to fold in the `publishConfig` entry points
|
|
56
|
-
*
|
|
49
|
+
* and publish, then RESTORES every one it touched
|
|
57
50
|
* byte-for-byte (and re-locks the mode) on the way out - see
|
|
58
51
|
* {@link restoreManifests}. Restore returns each manifest to its committed
|
|
59
52
|
* content, which already equals the release version, so the worktree is never
|
|
@@ -92,17 +85,9 @@ interface ManifestBackup {
|
|
|
92
85
|
/**
|
|
93
86
|
* Original state of every manifest this task has unlocked, keyed by path.
|
|
94
87
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* to reach the packed TARBALL, so every one is undone by {@link restoreManifests}
|
|
99
|
-
* before the process ends.
|
|
100
|
-
*
|
|
101
|
-
* Leaving them on disk is what used to strand a local `bun run bump` with ~34
|
|
102
|
-
* modified `package.json` files - version stamps and `lib/` entry points - after
|
|
103
|
-
* the release commit was already made, so the tree diverged from the commit that
|
|
104
|
-
* was just pushed and every subsequent `git status` needed a manual revert. CI
|
|
105
|
-
* never noticed because a fresh checkout is discarded.
|
|
88
|
+
* Bun does not apply `publishConfig` while packing, so publication temporarily
|
|
89
|
+
* projects those entry points onto projen-owned manifests. The projection is
|
|
90
|
+
* only meant for the packed tarball and is restored before the process exits.
|
|
106
91
|
*/
|
|
107
92
|
const manifestBackups = new Map<string, ManifestBackup>();
|
|
108
93
|
|
|
@@ -201,11 +186,9 @@ async function runConcurrent<T>(
|
|
|
201
186
|
}
|
|
202
187
|
|
|
203
188
|
/**
|
|
204
|
-
* Make a projen-readonly manifest writable so
|
|
205
|
-
* edit it, recording its original bytes
|
|
206
|
-
* first time it is seen.
|
|
207
|
-
* then `publishConfig`) keeps its PRE-EDIT backup rather than overwriting it with
|
|
208
|
-
* the already-stamped content.
|
|
189
|
+
* Make a projen-readonly manifest writable so the publishConfig projection can
|
|
190
|
+
* edit it, recording its original bytes and mode for {@link restoreManifests}
|
|
191
|
+
* the first time it is seen.
|
|
209
192
|
*/
|
|
210
193
|
function unlockManifest(pkgPath: string): void {
|
|
211
194
|
const mode = statSync(pkgPath).mode;
|
|
@@ -220,11 +203,9 @@ function unlockManifest(pkgPath: string): void {
|
|
|
220
203
|
*
|
|
221
204
|
* Runs from an `exit` handler so a clean finish and a failure partway through the
|
|
222
205
|
* publish loop are covered alike - a `bun publish` that dies on package 12 of 34
|
|
223
|
-
* must not leave the first 11
|
|
206
|
+
* must not leave the first 11 projected. Best-effort per file: one unwritable
|
|
224
207
|
* manifest is logged and the rest are still restored, since a partial restore
|
|
225
208
|
* beats none.
|
|
226
|
-
*
|
|
227
|
-
* Not registered under `--stamp-only`, where the stamps ARE the deliverable.
|
|
228
209
|
*/
|
|
229
210
|
function restoreManifests(): void {
|
|
230
211
|
for (const [pkgPath, backup] of manifestBackups) {
|
|
@@ -248,8 +229,7 @@ const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const
|
|
|
248
229
|
* doc). Idempotent, writes only when something changes, and leaves `publishConfig`
|
|
249
230
|
* in place (npm ignores it once the top-level fields already point at `lib/`). The
|
|
250
231
|
* manifest must already be unlocked. {@link restoreManifests} puts the `.ts` entry
|
|
251
|
-
* points back at exit, so this only ever affects the packed tarball
|
|
252
|
-
* version stamp.
|
|
232
|
+
* points back at exit, so this only ever affects the packed tarball.
|
|
253
233
|
*/
|
|
254
234
|
function applyPublishConfig(pkgPath: string): void {
|
|
255
235
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
@@ -292,13 +272,8 @@ if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
|
|
|
292
272
|
throw new Error(`--concurrency must be a positive integer, got ${String(parsedConcurrency)}`);
|
|
293
273
|
}
|
|
294
274
|
const concurrency = parsedConcurrency;
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
// workspace sibling versions resolved before it starts.
|
|
298
|
-
const stampOnly = rest.includes("--stamp-only");
|
|
299
|
-
// Undo the manifest edits at exit unless a later process still needs them. Implied
|
|
300
|
-
// off by `--stamp-only`, whose stamps exist precisely for a subsequent `bun publish`.
|
|
301
|
-
const restore = !stampOnly && !rest.includes("--no-restore");
|
|
275
|
+
// Undo transient publishConfig edits at exit.
|
|
276
|
+
const restore = !rest.includes("--no-restore");
|
|
302
277
|
const excluded = new Set(
|
|
303
278
|
rest.reduce<string[]>((acc, arg, i) => (arg === "--exclude" ? [...acc, rest[i + 1]] : acc), []),
|
|
304
279
|
);
|
|
@@ -313,18 +288,11 @@ const members = workspaceMembers(root)
|
|
|
313
288
|
.filter((dir) => existsSync(join(dir, "package.json")))
|
|
314
289
|
.filter((dir) => !excluded.has(resolve(root, dir).replace(`${resolve(root)}/`, "")));
|
|
315
290
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (manifestsMatchVersion(members, version)) {
|
|
320
|
-
logger.info(`all ${members.length} member manifests already carry ${version}`);
|
|
321
|
-
} else {
|
|
322
|
-
logger.info(`setting ${version} across ${members.length} members`);
|
|
323
|
-
await runConcurrent(members, concurrency, async (dir) => {
|
|
324
|
-
unlockManifest(join(dir, "package.json"));
|
|
325
|
-
await runAsync(dir, "bun", ["pm", "pkg", "set", `version=${version}`], path);
|
|
326
|
-
});
|
|
291
|
+
// Publication consumes the reviewed version state and never repairs it.
|
|
292
|
+
if (!manifestsMatchVersion(members, version)) {
|
|
293
|
+
throw new Error(`workspace manifests do not all match release version ${version}; run projen`);
|
|
327
294
|
}
|
|
295
|
+
logger.info(`all ${members.length} member manifests carry ${version}`);
|
|
328
296
|
|
|
329
297
|
// Ensure the lockfile resolves each `workspace:*` to the release version.
|
|
330
298
|
// `bun publish`/`pm pack` reads workspace versions from the LOCKFILE, not just
|
|
@@ -339,11 +307,6 @@ if (lockfileMatchesVersion(root, members, version)) {
|
|
|
339
307
|
run(root, "bun", ["install"], path);
|
|
340
308
|
}
|
|
341
309
|
|
|
342
|
-
if (stampOnly) {
|
|
343
|
-
logger.success(`stamped ${members.length} members @ ${version} (no publish)`);
|
|
344
|
-
process.exit(0);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
310
|
const publishArgs = [
|
|
348
311
|
"--access",
|
|
349
312
|
"public",
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun
|
|
2
|
+
/**
|
|
3
|
+
* Prepare a reviewed release pull request from the current branch.
|
|
4
|
+
*
|
|
5
|
+
* Pending work is committed and the current branch is pushed first. A dedicated
|
|
6
|
+
* release branch then receives the VERSION change, generated files, validation,
|
|
7
|
+
* and local registry preflight. Public publication remains owned by the
|
|
8
|
+
* main-branch release workflow.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { exec, project } from "@dbx-tools/core";
|
|
14
|
+
import { log } from "@dbx-tools/shared-core";
|
|
15
|
+
import { Command, Option } from "commander";
|
|
16
|
+
import { publishLocalRelease } from "./local-publish.ts";
|
|
17
|
+
import {
|
|
18
|
+
type VersionLevel,
|
|
19
|
+
readWorkspaceVersion,
|
|
20
|
+
resolveNextVersion,
|
|
21
|
+
} from "../src/workspace-version.ts";
|
|
22
|
+
|
|
23
|
+
const logger = log.logger("projen:release");
|
|
24
|
+
const LEVELS = ["patch", "minor", "major"] as const;
|
|
25
|
+
const RELEASE_OSES = ["darwin", "linux", "win32"] as const;
|
|
26
|
+
const RELEASE_ARCHES = ["arm64", "x64"] as const;
|
|
27
|
+
type ReleaseOs = (typeof RELEASE_OSES)[number];
|
|
28
|
+
type ReleaseArch = (typeof RELEASE_ARCHES)[number];
|
|
29
|
+
|
|
30
|
+
function collectValue<T extends string>(value: T, previous: T[]): T[] {
|
|
31
|
+
return [...previous, value];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(
|
|
35
|
+
root: string,
|
|
36
|
+
args: string[],
|
|
37
|
+
{ capture = false, check = true }: { capture?: boolean; check?: boolean } = {},
|
|
38
|
+
): string {
|
|
39
|
+
const result = exec.spawnSync("git", args, {
|
|
40
|
+
cwd: root,
|
|
41
|
+
stdout: capture ? "capture" : "inherit",
|
|
42
|
+
stderr: capture ? "ignore" : "inherit",
|
|
43
|
+
stdin: "ignore",
|
|
44
|
+
check,
|
|
45
|
+
});
|
|
46
|
+
return result.stdout?.trim() ?? "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pushCurrentBranch(root: string, branch: string): void {
|
|
50
|
+
const upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], {
|
|
51
|
+
capture: true,
|
|
52
|
+
check: false,
|
|
53
|
+
});
|
|
54
|
+
git(root, upstream ? ["push"] : ["push", "--set-upstream", "origin", branch]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gitSucceeds(root: string, args: string[]): boolean {
|
|
58
|
+
return (
|
|
59
|
+
exec.spawnSync("git", args, {
|
|
60
|
+
cwd: root,
|
|
61
|
+
stdout: "ignore",
|
|
62
|
+
stderr: "ignore",
|
|
63
|
+
stdin: "ignore",
|
|
64
|
+
check: false,
|
|
65
|
+
}).exitCode === 0
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function run(root: string, command: string, args: string[], env?: NodeJS.ProcessEnv): void {
|
|
70
|
+
exec.spawnSync(command, args, {
|
|
71
|
+
cwd: root,
|
|
72
|
+
env,
|
|
73
|
+
stdout: "inherit",
|
|
74
|
+
stderr: "inherit",
|
|
75
|
+
stdin: "ignore",
|
|
76
|
+
check: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function githubAccount(root: string): { owner: string; token: string } {
|
|
81
|
+
const repository = project.repositoryUrl(root);
|
|
82
|
+
if (!repository) throw new Error("Release preparation requires a GitHub repository");
|
|
83
|
+
const owner = new URL(repository).pathname.split("/").filter(Boolean)[0];
|
|
84
|
+
if (!owner) throw new Error(`Cannot determine GitHub owner from ${repository}`);
|
|
85
|
+
const token = exec
|
|
86
|
+
.spawnSync("gh", ["auth", "token", "--user", owner], {
|
|
87
|
+
cwd: root,
|
|
88
|
+
stdout: "capture",
|
|
89
|
+
stderr: "ignore",
|
|
90
|
+
stdin: "ignore",
|
|
91
|
+
check: true,
|
|
92
|
+
})
|
|
93
|
+
.stdout?.trim();
|
|
94
|
+
if (!token) throw new Error(`No GitHub CLI authentication found for ${owner}`);
|
|
95
|
+
return { owner, token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const program = new Command();
|
|
99
|
+
program
|
|
100
|
+
.description("Prepare, validate, locally publish, and open a reviewed release PR")
|
|
101
|
+
.addOption(
|
|
102
|
+
new Option("-l, --level <level>", "semver increment").choices([...LEVELS]).default("patch"),
|
|
103
|
+
)
|
|
104
|
+
.option("--prefix <prefix>", "release tag prefix", "v")
|
|
105
|
+
.option("--base <branch>", "release pull request base branch", "main")
|
|
106
|
+
.option("--message <message>", "commit message for pending source work", "chore: prepare release")
|
|
107
|
+
.addOption(
|
|
108
|
+
new Option("--os <os>", "release operating system, repeatable; crossed with every --arch")
|
|
109
|
+
.choices([...RELEASE_OSES])
|
|
110
|
+
.argParser((value, previous: ReleaseOs[]) => collectValue(value as ReleaseOs, previous))
|
|
111
|
+
.default([] as ReleaseOs[]),
|
|
112
|
+
)
|
|
113
|
+
.addOption(
|
|
114
|
+
new Option("--arch <arch>", "release CPU architecture, repeatable; crossed with every --os")
|
|
115
|
+
.choices([...RELEASE_ARCHES])
|
|
116
|
+
.argParser((value, previous: ReleaseArch[]) => collectValue(value as ReleaseArch, previous))
|
|
117
|
+
.default([] as ReleaseArch[]),
|
|
118
|
+
)
|
|
119
|
+
.option("--local-registry <value>", "local npm registry: auto, false, or an explicit URL", "auto")
|
|
120
|
+
.option("--local-pypi <value>", "local PyPI index: auto, false, or an explicit URL", "auto")
|
|
121
|
+
.option("--python-root <path>", "Python workspace package root", "packages/py")
|
|
122
|
+
.option("--no-local-cargo", "skip local Cargo publication")
|
|
123
|
+
.action(
|
|
124
|
+
async (opts: {
|
|
125
|
+
level: VersionLevel;
|
|
126
|
+
prefix: string;
|
|
127
|
+
base: string;
|
|
128
|
+
message: string;
|
|
129
|
+
os: ReleaseOs[];
|
|
130
|
+
arch: ReleaseArch[];
|
|
131
|
+
localRegistry: string;
|
|
132
|
+
localPypi: string;
|
|
133
|
+
pythonRoot: string;
|
|
134
|
+
localCargo: boolean;
|
|
135
|
+
}) => {
|
|
136
|
+
const root = project.root() ?? process.cwd();
|
|
137
|
+
const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
|
|
138
|
+
if (!currentBranch) throw new Error("Release preparation requires a local branch");
|
|
139
|
+
const account = githubAccount(root);
|
|
140
|
+
git(root, ["fetch", "--tags", "origin", opts.base]);
|
|
141
|
+
const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
|
|
142
|
+
const releaseTag = `${opts.prefix}${next.version}`;
|
|
143
|
+
const releaseBranch = `release/${releaseTag}`;
|
|
144
|
+
const resuming = currentBranch === releaseBranch;
|
|
145
|
+
if (currentBranch.startsWith("release/") && !resuming) {
|
|
146
|
+
throw new Error(`Expected release branch ${releaseBranch}, got ${currentBranch}`);
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
git(root, ["ls-remote", "--tags", "origin", `refs/tags/${releaseTag}`], {
|
|
150
|
+
capture: true,
|
|
151
|
+
check: false,
|
|
152
|
+
})
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(`Release tag already exists: ${releaseTag}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!resuming) {
|
|
158
|
+
if (!gitSucceeds(root, ["merge-base", "--is-ancestor", `origin/${opts.base}`, "HEAD"])) {
|
|
159
|
+
throw new Error(`Current branch must contain origin/${opts.base}`);
|
|
160
|
+
}
|
|
161
|
+
const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
162
|
+
capture: true,
|
|
163
|
+
});
|
|
164
|
+
if (status) {
|
|
165
|
+
git(root, ["add", "-A"]);
|
|
166
|
+
git(root, ["commit", "-m", opts.message]);
|
|
167
|
+
}
|
|
168
|
+
pushCurrentBranch(root, currentBranch);
|
|
169
|
+
|
|
170
|
+
const localBranch = git(root, ["branch", "--list", releaseBranch], { capture: true });
|
|
171
|
+
const remoteBranch = git(
|
|
172
|
+
root,
|
|
173
|
+
["ls-remote", "--heads", "origin", `refs/heads/${releaseBranch}`],
|
|
174
|
+
{ capture: true, check: false },
|
|
175
|
+
);
|
|
176
|
+
if (localBranch || remoteBranch) {
|
|
177
|
+
throw new Error(`Release branch already exists: ${releaseBranch}`);
|
|
178
|
+
}
|
|
179
|
+
git(root, ["switch", "--create", releaseBranch]);
|
|
180
|
+
} else {
|
|
181
|
+
logger.info(`resuming ${releaseBranch}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const bumpScript = fileURLToPath(new URL("./bump.ts", import.meta.url));
|
|
185
|
+
const versionCheckScript = fileURLToPath(new URL("./version-check.ts", import.meta.url));
|
|
186
|
+
run(root, process.execPath, [
|
|
187
|
+
bumpScript,
|
|
188
|
+
"--level",
|
|
189
|
+
opts.level,
|
|
190
|
+
"--prefix",
|
|
191
|
+
opts.prefix,
|
|
192
|
+
...opts.os.flatMap((value) => ["--os", value]),
|
|
193
|
+
...opts.arch.flatMap((value) => ["--arch", value]),
|
|
194
|
+
]);
|
|
195
|
+
if (readWorkspaceVersion(root) !== next.version) {
|
|
196
|
+
throw new Error(`Release preparation did not produce ${next.version}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
run(root, process.execPath, [versionCheckScript]);
|
|
200
|
+
if (existsSync(join(root, "Cargo.toml"))) {
|
|
201
|
+
run(root, "cargo", [
|
|
202
|
+
"test",
|
|
203
|
+
"--workspace",
|
|
204
|
+
...(existsSync(join(root, "Cargo.lock")) ? ["--locked"] : []),
|
|
205
|
+
]);
|
|
206
|
+
run(root, process.execPath, ["run", "rs:bindings"]);
|
|
207
|
+
}
|
|
208
|
+
run(root, process.execPath, ["run", "compile"]);
|
|
209
|
+
run(root, process.execPath, ["run", "test"]);
|
|
210
|
+
await publishLocalRelease({
|
|
211
|
+
root,
|
|
212
|
+
version: next.version,
|
|
213
|
+
localRegistry: opts.localRegistry,
|
|
214
|
+
localPypi: opts.localPypi,
|
|
215
|
+
pythonRoot: opts.pythonRoot,
|
|
216
|
+
localCargo: opts.localCargo,
|
|
217
|
+
});
|
|
218
|
+
run(root, process.execPath, [versionCheckScript]);
|
|
219
|
+
|
|
220
|
+
git(root, ["add", "-A"]);
|
|
221
|
+
const staged = git(root, ["diff", "--cached", "--name-only"], { capture: true });
|
|
222
|
+
if (staged) {
|
|
223
|
+
git(root, ["commit", "-m", `chore(release): ${next.version}`]);
|
|
224
|
+
} else if (!resuming) {
|
|
225
|
+
throw new Error("Release preparation produced no changes");
|
|
226
|
+
}
|
|
227
|
+
git(root, ["push", "--set-upstream", "origin", releaseBranch]);
|
|
228
|
+
|
|
229
|
+
const title = `chore(release): ${next.version}`;
|
|
230
|
+
const body = [
|
|
231
|
+
`Release ${releaseTag}.`,
|
|
232
|
+
"",
|
|
233
|
+
`Source commit: ${git(root, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
|
|
234
|
+
"",
|
|
235
|
+
"Merging this PR updates VERSION on main and starts the public release workflow.",
|
|
236
|
+
].join("\n");
|
|
237
|
+
run(
|
|
238
|
+
root,
|
|
239
|
+
"gh",
|
|
240
|
+
[
|
|
241
|
+
"pr",
|
|
242
|
+
"create",
|
|
243
|
+
"--base",
|
|
244
|
+
opts.base,
|
|
245
|
+
"--head",
|
|
246
|
+
releaseBranch,
|
|
247
|
+
"--title",
|
|
248
|
+
title,
|
|
249
|
+
"--body",
|
|
250
|
+
body,
|
|
251
|
+
],
|
|
252
|
+
{ ...process.env, GH_TOKEN: account.token },
|
|
253
|
+
);
|
|
254
|
+
if (!resuming) git(root, ["switch", currentBranch]);
|
|
255
|
+
logger.success(`opened ${releaseBranch} for ${releaseTag}`);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await program.parseAsync();
|