@dbx-tools/projen 0.9.47 → 0.9.51
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 +44 -5
- package/index.ts +2 -2
- package/package.json +5 -5
- package/src/project-py.ts +37 -2
- package/src/python-node-bindings.ts +32 -2
- package/src/python-node-bundle.ts +5 -0
- package/tasks/compile-workspace.ts +141 -2
- package/tasks/{lib/local-publish.ts → local-publish.ts} +8 -3
- package/tasks/publish-npm.ts +464 -2
- package/tasks/publish-python.ts +223 -2
- package/tasks/publish.ts +262 -2
- package/tasks/python-node-bindings.ts +1322 -2
- package/tasks/{lib/python-registry.ts → python-registry.ts} +4 -2
- package/tasks/python-sync.ts +248 -0
- package/tasks/release-version.ts +64 -2
- package/tasks/release.ts +272 -2
- package/tasks/lib/compile-workspace.ts +0 -141
- package/tasks/lib/publish-npm.ts +0 -466
- package/tasks/lib/publish-python.ts +0 -213
- package/tasks/lib/publish.ts +0 -264
- package/tasks/lib/python-node-bindings.ts +0 -1343
- package/tasks/lib/release-version.ts +0 -66
- package/tasks/lib/release.ts +0 -278
- /package/tasks/{lib/python-release.ts → python-release.ts} +0 -0
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { net } from "@dbx-tools/shared-core";
|
|
6
6
|
import { parse } from "smol-toml";
|
|
7
|
-
import { captureTaskCommand } from "
|
|
7
|
+
import { captureTaskCommand } from "../src/_task-command.ts";
|
|
8
8
|
|
|
9
9
|
/** Paired Python package index and upload endpoints for a local registry. */
|
|
10
10
|
export interface LocalPythonRegistry {
|
|
@@ -23,7 +23,9 @@ interface UvIndex {
|
|
|
23
23
|
function uvIndexes(source: string): UvIndex[] {
|
|
24
24
|
const document = parse(source) as { index?: unknown };
|
|
25
25
|
return Array.isArray(document.index)
|
|
26
|
-
? document.index.filter((value): value is UvIndex =>
|
|
26
|
+
? document.index.filter((value): value is UvIndex =>
|
|
27
|
+
Boolean(value && typeof value === "object"),
|
|
28
|
+
)
|
|
27
29
|
: [];
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun
|
|
2
|
+
/**
|
|
3
|
+
* Synchronize pinned Git source subsets into Python generated package trees.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
cpSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
23
|
+
import { withFileLock } from "@dbx-tools/core/file-lock";
|
|
24
|
+
import { parse } from "smol-toml";
|
|
25
|
+
|
|
26
|
+
interface SyncConfig {
|
|
27
|
+
readonly name?: string;
|
|
28
|
+
readonly source: string;
|
|
29
|
+
readonly include?: readonly string[];
|
|
30
|
+
readonly exclude?: readonly string[];
|
|
31
|
+
readonly replace?: Readonly<Record<string, string>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SyncManifest {
|
|
35
|
+
readonly optionsHash: string;
|
|
36
|
+
readonly resolvedCommit: string;
|
|
37
|
+
readonly source: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ParsedSource {
|
|
41
|
+
readonly repository: string;
|
|
42
|
+
readonly reference: string;
|
|
43
|
+
readonly subdirectory?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function main(args: readonly string[] = process.argv.slice(2)): Promise<void> {
|
|
47
|
+
const projectIndex = args.indexOf("--project");
|
|
48
|
+
if (projectIndex < 0 || !args[projectIndex + 1]) {
|
|
49
|
+
throw new Error("python-sync requires --project <directory>");
|
|
50
|
+
}
|
|
51
|
+
const project = resolve(args[projectIndex + 1]!);
|
|
52
|
+
const force = args.includes("--force");
|
|
53
|
+
const check = args.includes("--check");
|
|
54
|
+
const pyproject = parse(readFileSync(join(project, "pyproject.toml"), "utf8")) as {
|
|
55
|
+
tool?: {
|
|
56
|
+
uv?: { "build-backend"?: { "module-name"?: string; "module-root"?: string } };
|
|
57
|
+
dbx_tools?: { sync?: SyncConfig | SyncConfig[] };
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const configured = pyproject.tool?.dbx_tools?.sync;
|
|
61
|
+
const configs = configured ? (Array.isArray(configured) ? configured : [configured]) : [];
|
|
62
|
+
const build = pyproject.tool?.uv?.["build-backend"];
|
|
63
|
+
const moduleName = build?.["module-name"];
|
|
64
|
+
if (!moduleName) throw new Error(`${project}/pyproject.toml has no uv module-name`);
|
|
65
|
+
const moduleRoot = resolve(project, build?.["module-root"] ?? "src");
|
|
66
|
+
const generatedRoot = join(moduleRoot, ...moduleName.split("."), "_generated", "sync");
|
|
67
|
+
for (const config of configs) await synchronize(config, generatedRoot, { check, force });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function synchronize(
|
|
71
|
+
config: SyncConfig,
|
|
72
|
+
generatedRoot: string,
|
|
73
|
+
options: { readonly check: boolean; readonly force: boolean },
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const source = parseSource(config.source);
|
|
76
|
+
const name = config.name ?? defaultName(source);
|
|
77
|
+
const target = join(generatedRoot, name);
|
|
78
|
+
const optionsHash = hash({
|
|
79
|
+
exclude: [...(config.exclude ?? [])].sort(),
|
|
80
|
+
include: [...(config.include ?? ["**/*"])].sort(),
|
|
81
|
+
name,
|
|
82
|
+
replace: config.replace ?? {},
|
|
83
|
+
source: config.source,
|
|
84
|
+
});
|
|
85
|
+
const initialManifest = readManifest(target);
|
|
86
|
+
if (
|
|
87
|
+
!options.force &&
|
|
88
|
+
initialManifest?.optionsHash === optionsHash &&
|
|
89
|
+
/^[a-f0-9]{40}$/i.test(source.reference) &&
|
|
90
|
+
initialManifest.resolvedCommit === source.reference.toLowerCase()
|
|
91
|
+
) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const initialCommit = resolveCommit(source);
|
|
95
|
+
if (
|
|
96
|
+
!options.force &&
|
|
97
|
+
initialManifest?.optionsHash === optionsHash &&
|
|
98
|
+
initialManifest.resolvedCommit === initialCommit
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await withFileLock(["python-sync", target], async () => {
|
|
103
|
+
const resolvedCommit = resolveCommit(source);
|
|
104
|
+
const manifest = readManifest(target);
|
|
105
|
+
if (
|
|
106
|
+
!options.force &&
|
|
107
|
+
manifest?.optionsHash === optionsHash &&
|
|
108
|
+
manifest.resolvedCommit === resolvedCommit
|
|
109
|
+
) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (options.check) throw new Error(`Generated Python sync is stale: ${target}`);
|
|
113
|
+
replaceTarget(config, source, target, { optionsHash, resolvedCommit });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function replaceTarget(
|
|
118
|
+
config: SyncConfig,
|
|
119
|
+
source: ParsedSource,
|
|
120
|
+
target: string,
|
|
121
|
+
manifest: Omit<SyncManifest, "source">,
|
|
122
|
+
): void {
|
|
123
|
+
const temporary = mkdtempSync(join(tmpdir(), "dbx-tools-python-sync-"));
|
|
124
|
+
const checkout = join(temporary, "checkout");
|
|
125
|
+
const staging = join(temporary, "staging");
|
|
126
|
+
try {
|
|
127
|
+
execFileSync("git", ["init", "--quiet", checkout]);
|
|
128
|
+
execFileSync("git", [
|
|
129
|
+
"-C",
|
|
130
|
+
checkout,
|
|
131
|
+
"fetch",
|
|
132
|
+
"--quiet",
|
|
133
|
+
"--depth=1",
|
|
134
|
+
source.repository,
|
|
135
|
+
source.reference,
|
|
136
|
+
]);
|
|
137
|
+
execFileSync("git", ["-C", checkout, "checkout", "--quiet", "FETCH_HEAD"]);
|
|
138
|
+
const root = resolve(checkout, source.subdirectory ?? ".");
|
|
139
|
+
mkdirSync(staging, { recursive: true });
|
|
140
|
+
const included = new Set<string>();
|
|
141
|
+
const replacements = new Map(Object.keys(config.replace ?? {}).map((value) => [value, 0]));
|
|
142
|
+
for (const pattern of config.include ?? ["**/*"]) {
|
|
143
|
+
for (const relative of new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: true })) {
|
|
144
|
+
included.add(relative);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const excluded = (config.exclude ?? []).map((pattern) => new Bun.Glob(pattern));
|
|
148
|
+
for (const relative of [...included].sort()) {
|
|
149
|
+
if (excluded.some((glob) => glob.match(relative))) continue;
|
|
150
|
+
const destination = join(staging, relative);
|
|
151
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
152
|
+
cpSync(join(root, relative), destination);
|
|
153
|
+
if (relative.endsWith(".py") && config.replace) {
|
|
154
|
+
let contents = readFileSync(destination, "utf8");
|
|
155
|
+
for (const [from, to] of Object.entries(config.replace)) {
|
|
156
|
+
const matches = contents.split(from).length - 1;
|
|
157
|
+
if (matches > 0) replacements.set(from, (replacements.get(from) ?? 0) + matches);
|
|
158
|
+
contents = contents.replaceAll(from, to);
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(destination, contents);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const missing = [...replacements]
|
|
164
|
+
.filter(([, matches]) => matches === 0)
|
|
165
|
+
.map(([value]) => value);
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Python sync replacements did not match ${source.repository}: ${missing.join(", ")}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const license = join(checkout, "LICENSE");
|
|
172
|
+
if (existsSync(license)) cpSync(license, join(staging, "LICENSE.upstream"));
|
|
173
|
+
writeFileSync(
|
|
174
|
+
join(staging, ".sync.json"),
|
|
175
|
+
`${JSON.stringify({ ...manifest, source: config.source }, null, 2)}\n`,
|
|
176
|
+
);
|
|
177
|
+
makeWritable(target);
|
|
178
|
+
rmSync(target, { recursive: true, force: true });
|
|
179
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
180
|
+
renameSync(staging, target);
|
|
181
|
+
makeReadonly(target);
|
|
182
|
+
} finally {
|
|
183
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseSource(value: string): ParsedSource {
|
|
188
|
+
const match = /^(?:(.*?)\s+@\s+)?git\+(.+?)@([^#]+)(?:#subdirectory=(.+))?$/.exec(value);
|
|
189
|
+
if (!match) throw new Error(`Invalid pip Git source: ${value}`);
|
|
190
|
+
return {
|
|
191
|
+
repository: match[2]!,
|
|
192
|
+
reference: match[3]!,
|
|
193
|
+
...(match[4] ? { subdirectory: match[4] } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveCommit(source: ParsedSource): string {
|
|
198
|
+
if (/^[a-f0-9]{40}$/i.test(source.reference)) return source.reference.toLowerCase();
|
|
199
|
+
const output = execFileSync("git", ["ls-remote", source.repository, source.reference], {
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
}).trim();
|
|
202
|
+
const commit = output.split(/\s+/, 1)[0];
|
|
203
|
+
if (!commit) throw new Error(`Could not resolve ${source.reference} from ${source.repository}`);
|
|
204
|
+
return commit.toLowerCase();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function defaultName(source: ParsedSource): string {
|
|
208
|
+
const repository = basename(source.repository, ".git");
|
|
209
|
+
const owner = basename(dirname(source.repository));
|
|
210
|
+
return `${safeName(owner)}-${safeName(repository)}-${hash(source.repository).slice(0, 8)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function safeName(value: string): string {
|
|
214
|
+
return value
|
|
215
|
+
.toLowerCase()
|
|
216
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
217
|
+
.replace(/^-|-$/g, "");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hash(value: unknown): string {
|
|
221
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readManifest(target: string): SyncManifest | undefined {
|
|
225
|
+
const path = join(target, ".sync.json");
|
|
226
|
+
return existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as SyncManifest) : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function makeWritable(path: string): void {
|
|
230
|
+
if (!existsSync(path)) return;
|
|
231
|
+
if (statSync(path).isDirectory()) {
|
|
232
|
+
chmodSync(path, 0o755);
|
|
233
|
+
for (const entry of readdirSync(path)) makeWritable(join(path, entry));
|
|
234
|
+
} else {
|
|
235
|
+
chmodSync(path, 0o644);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makeReadonly(path: string): void {
|
|
240
|
+
if (statSync(path).isDirectory()) {
|
|
241
|
+
for (const entry of readdirSync(path)) makeReadonly(join(path, entry));
|
|
242
|
+
chmodSync(path, 0o555);
|
|
243
|
+
} else {
|
|
244
|
+
chmodSync(path, 0o444);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (import.meta.main) await main();
|
package/tasks/release-version.ts
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun
|
|
2
|
-
|
|
2
|
+
/** Validate one stable release version against the canonical workspace policy. */
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import * as projectUtils from "@dbx-tools/core/project-utils";
|
|
6
|
+
import { captureTaskCommand } from "../src/_task-command.ts";
|
|
7
|
+
import { compareSemver, parseSemver, type Semver } from "../src/workspace-version.ts";
|
|
3
8
|
|
|
4
|
-
|
|
9
|
+
function publishedVersion(root: string, prefixes: readonly string[]): string | undefined {
|
|
10
|
+
let best: Semver | undefined;
|
|
11
|
+
for (const prefix of prefixes) {
|
|
12
|
+
const tags = captureTaskCommand(root, "git", [
|
|
13
|
+
"-c",
|
|
14
|
+
"versionsort.suffix=-",
|
|
15
|
+
"tag",
|
|
16
|
+
"--sort=-version:refname",
|
|
17
|
+
"--list",
|
|
18
|
+
`${prefix}*`,
|
|
19
|
+
]);
|
|
20
|
+
for (const tag of tags.split("\n")) {
|
|
21
|
+
if (!tag.startsWith(prefix)) continue;
|
|
22
|
+
const version = parseSemver(tag.slice(prefix.length));
|
|
23
|
+
if (version && (!best || compareSemver(version, best) > 0)) best = version;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return best?.join(".");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertReleaseVersion(
|
|
30
|
+
version: string,
|
|
31
|
+
options: { root?: string; prefixes?: readonly string[]; assertNext?: boolean } = {},
|
|
32
|
+
): void {
|
|
33
|
+
const parsed = parseSemver(version);
|
|
34
|
+
if (!parsed) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`release version must be an exact stable x.y.z, got ${JSON.stringify(version)}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!options.assertNext) return;
|
|
40
|
+
const root = resolve(options.root ?? projectUtils.root() ?? process.cwd());
|
|
41
|
+
const prefixes = options.prefixes?.length ? options.prefixes : ["v"];
|
|
42
|
+
const current = publishedVersion(root, prefixes);
|
|
43
|
+
const currentParsed = current ? parseSemver(current) : undefined;
|
|
44
|
+
if (currentParsed && compareSemver(parsed, currentParsed) <= 0) {
|
|
45
|
+
throw new Error(`release version ${version} must be greater than published ${current}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function main(args: string[] = process.argv.slice(2)): void {
|
|
50
|
+
const { values } = parseArgs({
|
|
51
|
+
args,
|
|
52
|
+
options: {
|
|
53
|
+
version: { type: "string" },
|
|
54
|
+
root: { type: "string" },
|
|
55
|
+
prefix: { type: "string", multiple: true },
|
|
56
|
+
"assert-next": { type: "boolean", default: false },
|
|
57
|
+
},
|
|
58
|
+
strict: true,
|
|
59
|
+
});
|
|
60
|
+
if (!values.version) throw new Error("--version is required");
|
|
61
|
+
assertReleaseVersion(values.version, {
|
|
62
|
+
...(values.root ? { root: values.root } : {}),
|
|
63
|
+
...(values.prefix ? { prefixes: values.prefix } : {}),
|
|
64
|
+
assertNext: values["assert-next"],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
5
67
|
|
|
6
68
|
if (import.meta.main) main();
|
package/tasks/release.ts
CHANGED
|
@@ -1,6 +1,276 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun
|
|
2
|
-
|
|
2
|
+
/** Run the configured release transaction. */
|
|
3
|
+
import * as projectUtils from "@dbx-tools/core/project-utils";
|
|
4
|
+
import { log } from "@dbx-tools/shared-core";
|
|
5
|
+
import { Command, Option } from "commander";
|
|
6
|
+
import { publishLocalRelease } from "./local-publish.ts";
|
|
7
|
+
import { assertReleaseVersion } from "./release-version.ts";
|
|
8
|
+
import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
|
|
9
|
+
import {
|
|
10
|
+
RELEASE_INSTALL_MODES,
|
|
11
|
+
RELEASE_PUBLISH_TARGETS,
|
|
12
|
+
releasePublishesLocally,
|
|
13
|
+
releaseStepSelection,
|
|
14
|
+
releaseTagAnnotation,
|
|
15
|
+
type ReleaseInstallMode,
|
|
16
|
+
type ReleaseSelectionOptions,
|
|
17
|
+
} from "../src/release-options.ts";
|
|
18
|
+
import { readWorkspaceVersion } from "../src/workspace-version.ts";
|
|
3
19
|
|
|
4
|
-
|
|
20
|
+
const logger = log.logger("projen:release");
|
|
21
|
+
|
|
22
|
+
function gitHead(root: string, ref: string): string | undefined {
|
|
23
|
+
if (!taskCommandSucceeds(root, "git", ["rev-parse", "--verify", ref])) return undefined;
|
|
24
|
+
return captureTaskCommand(root, "git", ["rev-parse", ref], { check: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertAncestor(root: string, ancestor: string, descendant: string, message: string): void {
|
|
28
|
+
if (!taskCommandSucceeds(root, "git", ["merge-base", "--is-ancestor", ancestor, descendant])) {
|
|
29
|
+
throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function prepareReleaseBranch(options: {
|
|
34
|
+
readonly branch: string;
|
|
35
|
+
readonly remote: string;
|
|
36
|
+
readonly root: string;
|
|
37
|
+
}): void {
|
|
38
|
+
const { branch, remote, root } = options;
|
|
39
|
+
const sourceBranch = captureTaskCommand(root, "git", ["branch", "--show-current"], {
|
|
40
|
+
check: true,
|
|
41
|
+
});
|
|
42
|
+
if (!sourceBranch) throw new Error("release cannot run from a detached HEAD");
|
|
43
|
+
|
|
44
|
+
const status = captureTaskCommand(
|
|
45
|
+
root,
|
|
46
|
+
"git",
|
|
47
|
+
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
48
|
+
{ check: true },
|
|
49
|
+
);
|
|
50
|
+
if (status) {
|
|
51
|
+
runTaskCommand(root, "git", ["add", "--all"]);
|
|
52
|
+
runTaskCommand(root, "git", ["commit", "-m", "chore: prepare release"]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
runTaskCommand(root, "git", ["fetch", "--prune", remote, "--tags"]);
|
|
56
|
+
const sourceHead = captureTaskCommand(root, "git", ["rev-parse", "HEAD"], { check: true });
|
|
57
|
+
const remoteSourceHead = gitHead(root, `refs/remotes/${remote}/${sourceBranch}`);
|
|
58
|
+
if (remoteSourceHead) {
|
|
59
|
+
assertAncestor(
|
|
60
|
+
root,
|
|
61
|
+
remoteSourceHead,
|
|
62
|
+
sourceHead,
|
|
63
|
+
`cannot fast-forward ${remote}/${sourceBranch} to ${sourceBranch}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
runTaskCommand(root, "git", ["push", remote, `HEAD:${sourceBranch}`]);
|
|
67
|
+
|
|
68
|
+
if (sourceBranch === branch) return;
|
|
69
|
+
|
|
70
|
+
const remoteMain = gitHead(root, `refs/remotes/${remote}/${branch}`);
|
|
71
|
+
if (!remoteMain) throw new Error(`release branch ${remote}/${branch} does not exist`);
|
|
72
|
+
assertAncestor(
|
|
73
|
+
root,
|
|
74
|
+
remoteMain,
|
|
75
|
+
sourceHead,
|
|
76
|
+
`cannot safely merge ${sourceBranch} into ${branch}; ${branch} is not an ancestor`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const localMain = gitHead(root, `refs/heads/${branch}`);
|
|
80
|
+
if (localMain) {
|
|
81
|
+
assertAncestor(
|
|
82
|
+
root,
|
|
83
|
+
localMain,
|
|
84
|
+
sourceHead,
|
|
85
|
+
`cannot safely fast-forward local ${branch} to ${sourceBranch}`,
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
runTaskCommand(root, "git", ["branch", branch, `${remote}/${branch}`]);
|
|
89
|
+
}
|
|
90
|
+
runTaskCommand(root, "git", ["switch", branch]);
|
|
91
|
+
runTaskCommand(root, "git", ["merge", "--ff-only", sourceBranch]);
|
|
92
|
+
runTaskCommand(root, "git", ["push", remote, `HEAD:${branch}`]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runRelease(
|
|
96
|
+
options: ReleaseSelectionOptions & {
|
|
97
|
+
readonly root: string;
|
|
98
|
+
readonly branch: string;
|
|
99
|
+
readonly bump?: boolean;
|
|
100
|
+
readonly prefix: string;
|
|
101
|
+
readonly remote: string;
|
|
102
|
+
readonly localPublish?: boolean;
|
|
103
|
+
readonly localRegistry?: string;
|
|
104
|
+
readonly localPypi?: string;
|
|
105
|
+
readonly install?: ReleaseInstallMode;
|
|
106
|
+
readonly pythonRoot?: string;
|
|
107
|
+
readonly validationTasks?: readonly string[];
|
|
108
|
+
},
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const { branch, prefix, remote, root } = options;
|
|
111
|
+
const selection = releaseStepSelection(options);
|
|
112
|
+
const install = options.install ?? "auto";
|
|
113
|
+
|
|
114
|
+
if (install === "always") runTaskCommand(root, "bun", ["install"]);
|
|
115
|
+
|
|
116
|
+
prepareReleaseBranch({ branch, remote, root });
|
|
117
|
+
|
|
118
|
+
runTaskCommand(root, "git", ["fetch", remote, branch, "--tags"]);
|
|
119
|
+
if (options.bump ?? true) {
|
|
120
|
+
runTaskCommand(root, "bun", ["run", "bump"], {
|
|
121
|
+
env: { ...process.env, DBX_TOOLS_RELEASE_INSTALL: install === "auto" ? "auto" : "never" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const version = readWorkspaceVersion(root);
|
|
126
|
+
const tag = `${prefix}${version}`;
|
|
127
|
+
assertReleaseVersion(version, { root, prefixes: [prefix], assertNext: true });
|
|
128
|
+
|
|
129
|
+
if (taskCommandSucceeds(root, "git", ["rev-parse", "--verify", `refs/tags/${tag}`])) {
|
|
130
|
+
throw new Error(`release tag ${tag} already exists`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
runTaskCommand(root, "bun", ["run", "version:check"]);
|
|
134
|
+
for (const task of selection.validation ? (options.validationTasks ?? []) : []) {
|
|
135
|
+
runTaskCommand(root, "bun", ["run", task]);
|
|
136
|
+
}
|
|
137
|
+
const status = captureTaskCommand(
|
|
138
|
+
root,
|
|
139
|
+
"git",
|
|
140
|
+
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
141
|
+
{ check: true },
|
|
142
|
+
);
|
|
143
|
+
if (status) {
|
|
144
|
+
if (!(options.bump ?? true)) {
|
|
145
|
+
throw new Error("--no-bump requires an already committed synchronized version");
|
|
146
|
+
}
|
|
147
|
+
if (taskCommandSucceeds(root, "git", ["diff", "--quiet", "HEAD", "--", "VERSION"])) {
|
|
148
|
+
throw new Error("release changes must include the local VERSION bump");
|
|
149
|
+
}
|
|
150
|
+
runTaskCommand(root, "git", ["add", "--all"]);
|
|
151
|
+
runTaskCommand(root, "git", ["commit", "-m", `chore(release): ${version}`]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const remoteHead = captureTaskCommand(root, "git", ["rev-parse", `${remote}/${branch}`], {
|
|
155
|
+
check: true,
|
|
156
|
+
});
|
|
157
|
+
const head = captureTaskCommand(root, "git", ["rev-parse", "HEAD"], { check: true });
|
|
158
|
+
if (!taskCommandSucceeds(root, "git", ["merge-base", "--is-ancestor", remoteHead, head])) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`cannot fast-forward ${remote}/${branch} to HEAD; ${branch} has diverged (rebase before release)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
runTaskCommand(root, "git", [
|
|
165
|
+
"tag",
|
|
166
|
+
"--annotate",
|
|
167
|
+
tag,
|
|
168
|
+
"--message",
|
|
169
|
+
releaseTagAnnotation(tag, selection),
|
|
170
|
+
]);
|
|
171
|
+
try {
|
|
172
|
+
runTaskCommand(root, "git", ["push", "--atomic", remote, `HEAD:${branch}`, `refs/tags/${tag}`]);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
runTaskCommand(root, "git", ["tag", "--delete", tag]);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
logger.success(`released ${tag}`, { sha: head });
|
|
178
|
+
|
|
179
|
+
// Restore local deploys: when a non-standard (loopback) npm or uv registry is
|
|
180
|
+
// configured, publish the freshly tagged version to it directly. Detection and
|
|
181
|
+
// publishing are owned by local-publish.ts; this no-ops on standard registries.
|
|
182
|
+
if ((options.localPublish ?? true) && releasePublishesLocally(options.publish)) {
|
|
183
|
+
await publishLocalRelease({
|
|
184
|
+
localPypi: options.pypi === false ? "false" : (options.localPypi ?? "auto"),
|
|
185
|
+
localRegistry: options.npm === false ? "false" : (options.localRegistry ?? "auto"),
|
|
186
|
+
pythonRoot: options.pythonRoot ?? "packages/py",
|
|
187
|
+
root,
|
|
188
|
+
version,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return tag;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Build the release task's native parser without executing git or registry operations. */
|
|
196
|
+
export function createReleaseCommand(): Command {
|
|
197
|
+
return new Command()
|
|
198
|
+
.name("release")
|
|
199
|
+
.description("Prepare an annotated release and select its build and publication steps")
|
|
200
|
+
.option("--root <path>", "repository root")
|
|
201
|
+
.option("--branch <name>", "release branch", "main")
|
|
202
|
+
.option("--prefix <prefix>", "release tag prefix", "v")
|
|
203
|
+
.option("--remote <name>", "git remote", "origin")
|
|
204
|
+
.option("--python-root <path>", "Python package root for local publish", "packages/py")
|
|
205
|
+
.option(
|
|
206
|
+
"--validate <task>",
|
|
207
|
+
"task to run before pushing",
|
|
208
|
+
(task, tasks: string[]) => [...tasks, task],
|
|
209
|
+
[],
|
|
210
|
+
)
|
|
211
|
+
.option("--no-bump", "use an existing synchronized local version bump")
|
|
212
|
+
.addOption(
|
|
213
|
+
new Option("--publish <target>", "publication scope")
|
|
214
|
+
.choices([...RELEASE_PUBLISH_TARGETS])
|
|
215
|
+
.default("auto"),
|
|
216
|
+
)
|
|
217
|
+
.addOption(
|
|
218
|
+
new Option("--install <mode>", "local workspace dependency installation")
|
|
219
|
+
.choices([...RELEASE_INSTALL_MODES])
|
|
220
|
+
.default("auto"),
|
|
221
|
+
)
|
|
222
|
+
.option("--no-npm", "skip npm build and publication, including local npm publication")
|
|
223
|
+
.option("--no-pypi", "skip Python build and publication, including local Python publication")
|
|
224
|
+
.option("--docs", "build and deploy docs for a selected scope")
|
|
225
|
+
.option("--no-docs", "skip documentation build and deployment")
|
|
226
|
+
.option(
|
|
227
|
+
"--no-validation",
|
|
228
|
+
"skip optional release validation tasks; version/source checks remain mandatory",
|
|
229
|
+
)
|
|
230
|
+
.option("--no-local-publish", "skip publishing to configured local registries")
|
|
231
|
+
.option("--local-registry <auto|false|url>", "local npm registry selection", "auto")
|
|
232
|
+
.option("--local-pypi <auto|false|url>", "local devpi registry selection", "auto")
|
|
233
|
+
.action(
|
|
234
|
+
async (
|
|
235
|
+
options: ReleaseSelectionOptions & {
|
|
236
|
+
root?: string;
|
|
237
|
+
branch: string;
|
|
238
|
+
bump: boolean;
|
|
239
|
+
prefix: string;
|
|
240
|
+
remote: string;
|
|
241
|
+
pythonRoot: string;
|
|
242
|
+
localPublish: boolean;
|
|
243
|
+
localRegistry: string;
|
|
244
|
+
localPypi: string;
|
|
245
|
+
install: ReleaseInstallMode;
|
|
246
|
+
validate: string[];
|
|
247
|
+
},
|
|
248
|
+
) => {
|
|
249
|
+
await runRelease({
|
|
250
|
+
root: options.root ?? projectUtils.root() ?? process.cwd(),
|
|
251
|
+
branch: options.branch,
|
|
252
|
+
bump: options.bump,
|
|
253
|
+
prefix: options.prefix,
|
|
254
|
+
remote: options.remote,
|
|
255
|
+
pythonRoot: options.pythonRoot,
|
|
256
|
+
localPublish: options.localPublish,
|
|
257
|
+
localRegistry: options.localRegistry,
|
|
258
|
+
localPypi: options.localPypi,
|
|
259
|
+
install: options.install,
|
|
260
|
+
publish: options.publish,
|
|
261
|
+
npm: options.npm,
|
|
262
|
+
pypi: options.pypi,
|
|
263
|
+
docs: options.docs,
|
|
264
|
+
validation: options.validation,
|
|
265
|
+
validationTasks: options.validate,
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Execute the native release task parser. */
|
|
272
|
+
export async function main(): Promise<void> {
|
|
273
|
+
await createReleaseCommand().parseAsync();
|
|
274
|
+
}
|
|
5
275
|
|
|
6
276
|
if (import.meta.main) await main();
|