@dbx-tools/projen 0.9.18 → 0.9.19

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,273 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Create, verify, and select immutable release-candidate assets. */
3
+
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ copyFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { basename, join, resolve } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+
18
+ export type ReleaseArtifactKind = "binary" | "npm" | "pypi";
19
+ export type ReleaseArtifactRole = "facade" | "native" | "workspace";
20
+
21
+ export interface ReleaseArtifactInput {
22
+ readonly kind: ReleaseArtifactKind;
23
+ readonly packageName?: string;
24
+ readonly packageVersion?: string;
25
+ readonly path: string;
26
+ readonly role?: ReleaseArtifactRole;
27
+ }
28
+
29
+ export interface ReleaseArtifact {
30
+ readonly kind: ReleaseArtifactKind;
31
+ readonly name: string;
32
+ readonly packageName?: string;
33
+ readonly packageVersion?: string;
34
+ readonly role?: ReleaseArtifactRole;
35
+ readonly sha256: string;
36
+ readonly size: number;
37
+ }
38
+
39
+ export interface ReleaseManifest {
40
+ readonly schemaVersion: 1;
41
+ readonly tag: string;
42
+ readonly version: string;
43
+ readonly gitSha: string;
44
+ readonly artifacts: readonly ReleaseArtifact[];
45
+ }
46
+
47
+ function sha256(path: string): string {
48
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
49
+ }
50
+
51
+ /** Copy release artifacts into one upload directory and write its trust manifest. */
52
+ export function writeReleaseManifest(options: {
53
+ readonly artifacts: readonly ReleaseArtifactInput[];
54
+ readonly directory: string;
55
+ readonly gitSha: string;
56
+ readonly tag: string;
57
+ readonly version: string;
58
+ }): ReleaseManifest {
59
+ const directory = resolve(options.directory);
60
+ rmSync(directory, { recursive: true, force: true });
61
+ mkdirSync(directory, { recursive: true });
62
+ const names = new Set<string>();
63
+ const artifacts = options.artifacts
64
+ .map((artifact): ReleaseArtifact => {
65
+ const source = resolve(artifact.path);
66
+ if (!existsSync(source)) throw new Error(`Missing release artifact: ${source}`);
67
+ const name = basename(source);
68
+ if (names.has(name)) throw new Error(`Duplicate release artifact name: ${name}`);
69
+ names.add(name);
70
+ copyFileSync(source, join(directory, name));
71
+ return {
72
+ kind: artifact.kind,
73
+ name,
74
+ ...(artifact.packageName ? { packageName: artifact.packageName } : {}),
75
+ ...(artifact.packageVersion ? { packageVersion: artifact.packageVersion } : {}),
76
+ ...(artifact.role ? { role: artifact.role } : {}),
77
+ sha256: sha256(source),
78
+ size: statSync(source).size,
79
+ };
80
+ })
81
+ .sort((left, right) => left.name.localeCompare(right.name));
82
+ const manifest: ReleaseManifest = {
83
+ schemaVersion: 1,
84
+ tag: options.tag,
85
+ version: options.version,
86
+ gitSha: options.gitSha,
87
+ artifacts,
88
+ };
89
+ writeFileSync(join(directory, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
90
+ writeFileSync(
91
+ join(directory, "SHA256SUMS"),
92
+ `${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`,
93
+ );
94
+ return manifest;
95
+ }
96
+
97
+ function readManifest(directory: string): ReleaseManifest {
98
+ const value = JSON.parse(
99
+ readFileSync(join(directory, "release-manifest.json"), "utf8"),
100
+ ) as unknown;
101
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
102
+ throw new Error("Unsupported release manifest");
103
+ }
104
+ const record = value as Record<string, unknown>;
105
+ if (
106
+ record.schemaVersion !== 1 ||
107
+ typeof record.tag !== "string" ||
108
+ typeof record.version !== "string" ||
109
+ typeof record.gitSha !== "string" ||
110
+ !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(record.gitSha) ||
111
+ !Array.isArray(record.artifacts)
112
+ ) {
113
+ throw new Error("Unsupported release manifest");
114
+ }
115
+ const names = new Set<string>();
116
+ const artifacts = record.artifacts.map((value): ReleaseArtifact => {
117
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
118
+ throw new Error("Invalid release artifact");
119
+ }
120
+ const artifact = value as Record<string, unknown>;
121
+ if (
122
+ !["binary", "npm", "pypi"].includes(String(artifact.kind)) ||
123
+ typeof artifact.name !== "string" ||
124
+ basename(artifact.name) !== artifact.name ||
125
+ artifact.name === "." ||
126
+ artifact.name === ".." ||
127
+ typeof artifact.sha256 !== "string" ||
128
+ !/^[0-9a-f]{64}$/.test(artifact.sha256) ||
129
+ !Number.isSafeInteger(artifact.size) ||
130
+ Number(artifact.size) < 0
131
+ ) {
132
+ throw new Error("Invalid release artifact");
133
+ }
134
+ if (names.has(artifact.name)) {
135
+ throw new Error(`Duplicate release artifact name: ${artifact.name}`);
136
+ }
137
+ names.add(artifact.name);
138
+ const kind = artifact.kind as ReleaseArtifactKind;
139
+ const packageName = artifact.packageName;
140
+ const packageVersion = artifact.packageVersion;
141
+ const role = artifact.role;
142
+ if (
143
+ (packageName !== undefined && typeof packageName !== "string") ||
144
+ (packageVersion !== undefined && typeof packageVersion !== "string") ||
145
+ (role !== undefined && !["facade", "native", "workspace"].includes(String(role))) ||
146
+ (kind !== "binary" && (!packageName || !packageVersion))
147
+ ) {
148
+ throw new Error(`Invalid release artifact metadata: ${artifact.name}`);
149
+ }
150
+ return {
151
+ kind,
152
+ name: artifact.name,
153
+ ...(packageName ? { packageName } : {}),
154
+ ...(packageVersion ? { packageVersion } : {}),
155
+ ...(role ? { role: role as ReleaseArtifactRole } : {}),
156
+ sha256: artifact.sha256,
157
+ size: Number(artifact.size),
158
+ };
159
+ });
160
+ return {
161
+ schemaVersion: 1,
162
+ tag: record.tag,
163
+ version: record.version,
164
+ gitSha: record.gitSha,
165
+ artifacts,
166
+ };
167
+ }
168
+
169
+ /** Verify a release bundle and optionally extract one registry/package subset. */
170
+ export function verifyReleaseManifest(options: {
171
+ readonly directory: string;
172
+ readonly gitSha: string;
173
+ readonly kind?: ReleaseArtifactKind;
174
+ readonly output?: string;
175
+ readonly packageName?: string;
176
+ readonly tag: string;
177
+ readonly version: string;
178
+ }): readonly ReleaseArtifact[] {
179
+ const directory = resolve(options.directory);
180
+ const manifest = readManifest(directory);
181
+ if (manifest.tag !== options.tag) {
182
+ throw new Error(`Release manifest tag ${manifest.tag} does not match ${options.tag}`);
183
+ }
184
+ if (manifest.version !== options.version) {
185
+ throw new Error(
186
+ `Release manifest version ${manifest.version} does not match ${options.version}`,
187
+ );
188
+ }
189
+ if (manifest.gitSha !== options.gitSha) {
190
+ throw new Error(`Release manifest SHA ${manifest.gitSha} does not match ${options.gitSha}`);
191
+ }
192
+ for (const artifact of manifest.artifacts) {
193
+ if (artifact.kind !== "binary" && artifact.packageVersion !== options.version) {
194
+ throw new Error(
195
+ `Release artifact ${artifact.name} carries ${String(artifact.packageVersion)}, expected ${options.version}`,
196
+ );
197
+ }
198
+ }
199
+ const expectedChecksums = `${manifest.artifacts
200
+ .map((artifact) => `${artifact.sha256} ${artifact.name}`)
201
+ .join("\n")}\n`;
202
+ const checksums = readFileSync(join(directory, "SHA256SUMS"), "utf8");
203
+ if (checksums !== expectedChecksums) throw new Error("SHA256SUMS does not match the manifest");
204
+ const selected = manifest.artifacts.filter(
205
+ (artifact) =>
206
+ (!options.kind || artifact.kind === options.kind) &&
207
+ (!options.packageName || artifact.packageName === options.packageName),
208
+ );
209
+ if ((options.kind || options.packageName) && selected.length === 0) {
210
+ throw new Error("Release manifest selection matched no artifacts");
211
+ }
212
+ if (!options.kind && !options.packageName) {
213
+ const expected = new Set([
214
+ "release-manifest.json",
215
+ "SHA256SUMS",
216
+ ...manifest.artifacts.map((artifact) => artifact.name),
217
+ ]);
218
+ const present = readdirSync(directory).filter((name) => !name.startsWith("."));
219
+ const unexpected = present.filter((name) => !expected.has(name));
220
+ if (unexpected.length > 0) {
221
+ throw new Error(`Release contains unmanifested assets: ${unexpected.sort().join(", ")}`);
222
+ }
223
+ }
224
+ const output = options.output ? resolve(options.output) : undefined;
225
+ if (output) {
226
+ rmSync(output, { recursive: true, force: true });
227
+ mkdirSync(output, { recursive: true });
228
+ }
229
+ for (const artifact of selected) {
230
+ const source = join(directory, artifact.name);
231
+ if (!existsSync(source)) throw new Error(`Missing release asset: ${artifact.name}`);
232
+ if (statSync(source).size !== artifact.size) {
233
+ throw new Error(`Release asset size does not match: ${artifact.name}`);
234
+ }
235
+ if (sha256(source) !== artifact.sha256) {
236
+ throw new Error(`Release asset checksum does not match: ${artifact.name}`);
237
+ }
238
+ if (output) copyFileSync(source, join(output, artifact.name));
239
+ }
240
+ return selected;
241
+ }
242
+
243
+ if (import.meta.main) {
244
+ const { positionals, values } = parseArgs({
245
+ args: process.argv.slice(2),
246
+ allowPositionals: true,
247
+ options: {
248
+ directory: { type: "string" },
249
+ kind: { type: "string" },
250
+ output: { type: "string" },
251
+ package: { type: "string" },
252
+ sha: { type: "string" },
253
+ tag: { type: "string" },
254
+ version: { type: "string" },
255
+ },
256
+ });
257
+ if (positionals[0] !== "verify") throw new Error("Expected verify command");
258
+ if (!values.directory || !values.sha || !values.tag || !values.version) {
259
+ throw new Error("verify requires --directory, --sha, --tag, and --version");
260
+ }
261
+ if (values.kind && !["binary", "npm", "pypi"].includes(values.kind)) {
262
+ throw new Error(`Unknown release artifact kind: ${values.kind}`);
263
+ }
264
+ verifyReleaseManifest({
265
+ directory: values.directory,
266
+ gitSha: values.sha,
267
+ ...(values.kind ? { kind: values.kind as ReleaseArtifactKind } : {}),
268
+ ...(values.output ? { output: values.output } : {}),
269
+ ...(values.package ? { packageName: values.package } : {}),
270
+ tag: values.tag,
271
+ version: values.version,
272
+ });
273
+ }
@@ -55,41 +55,6 @@ import {
55
55
 
56
56
  const logger = log.logger("projen:release");
57
57
 
58
- export type ReleaseBuildMode = "auto" | "local" | "remote";
59
-
60
- /** Resolve an explicit or environment-sensitive native release build mode. */
61
- export function resolveReleaseBuildMode(options: {
62
- readonly requested: ReleaseBuildMode;
63
- readonly canManage: boolean;
64
- readonly localTools: readonly string[];
65
- readonly hasLocalTargets: boolean;
66
- readonly approve: boolean;
67
- readonly wait: boolean;
68
- readonly hostPlatform?: NodeJS.Platform;
69
- }): Exclude<ReleaseBuildMode, "auto"> {
70
- const local =
71
- options.canManage &&
72
- options.hasLocalTargets &&
73
- (options.hostPlatform ?? process.platform) === "darwin" &&
74
- options.localTools.length === 0 &&
75
- options.approve &&
76
- options.wait;
77
- if (options.requested === "auto") return local ? "local" : "remote";
78
- if (options.requested === "remote") return "remote";
79
- if (!options.canManage) throw new Error("--build local requires maintain or admin permission");
80
- if (!options.hasLocalTargets) throw new Error("--build local requires Rust release targets");
81
- if ((options.hostPlatform ?? process.platform) !== "darwin") {
82
- throw new Error("--build local requires macOS");
83
- }
84
- if (options.localTools.length > 0) {
85
- throw new Error(`--build local requires: ${options.localTools.join(", ")}`);
86
- }
87
- if (!options.approve || !options.wait) {
88
- throw new Error("--build local requires automatic merge and waiting");
89
- }
90
- return "local";
91
- }
92
-
93
58
  function git(
94
59
  root: string,
95
60
  args: string[],
@@ -115,6 +80,34 @@ async function waitForPullRequestMerge(
115
80
  env: NodeJS.ProcessEnv,
116
81
  timeoutMs: number,
117
82
  ): Promise<string> {
83
+ const checksDeadline = Date.now() + timeoutMs;
84
+ let checksReported = false;
85
+ while (Date.now() < checksDeadline) {
86
+ const count = Number(
87
+ captureTaskCommand(
88
+ root,
89
+ "gh",
90
+ [
91
+ "pr",
92
+ "view",
93
+ releaseBranch,
94
+ "--json",
95
+ "statusCheckRollup",
96
+ "--jq",
97
+ ".statusCheckRollup | length",
98
+ ],
99
+ { env },
100
+ ),
101
+ );
102
+ if (Number.isInteger(count) && count > 0) {
103
+ checksReported = true;
104
+ break;
105
+ }
106
+ await asyncUtils.sleep(2_000);
107
+ }
108
+ if (!checksReported) {
109
+ throw new Error(`Release pull request reported no checks within ${timeoutMs}ms`);
110
+ }
118
111
  await runTaskCommandAsync(
119
112
  root,
120
113
  "gh",
@@ -145,64 +138,6 @@ async function waitForPullRequestMerge(
145
138
  throw new Error(`Release pull request did not merge within ${timeoutMs}ms`);
146
139
  }
147
140
 
148
- /** Find and watch the release workflow for an exact merged commit. */
149
- async function waitForReleaseWorkflow(
150
- root: string,
151
- baseBranch: string,
152
- mergeSha: string,
153
- env: NodeJS.ProcessEnv,
154
- timeoutMs: number,
155
- ): Promise<void> {
156
- const deadline = Date.now() + timeoutMs;
157
- let runId = "";
158
- while (Date.now() < deadline) {
159
- runId = captureTaskCommand(
160
- root,
161
- "gh",
162
- [
163
- "run",
164
- "list",
165
- "--workflow",
166
- "release.yml",
167
- "--branch",
168
- baseBranch,
169
- "--event",
170
- "push",
171
- "--commit",
172
- mergeSha,
173
- "--limit",
174
- "1",
175
- "--json",
176
- "databaseId",
177
- "--jq",
178
- ".[0].databaseId // empty",
179
- ],
180
- { env },
181
- );
182
- if (runId) break;
183
- await asyncUtils.sleep(5_000);
184
- }
185
- if (!runId) throw new Error(`Release workflow did not start within ${timeoutMs}ms`);
186
- await runTaskCommandAsync(root, "gh", ["run", "watch", runId, "--exit-status"], {
187
- env,
188
- signal: AbortSignal.timeout(timeoutMs),
189
- });
190
- }
191
-
192
- async function waitForGitHubRelease(
193
- root: string,
194
- tag: string,
195
- env: NodeJS.ProcessEnv,
196
- timeoutMs: number,
197
- ): Promise<void> {
198
- const deadline = Date.now() + timeoutMs;
199
- while (Date.now() < deadline) {
200
- if (taskCommandSucceeds(root, "gh", ["release", "view", tag], { env })) return;
201
- await asyncUtils.sleep(2_000);
202
- }
203
- throw new Error(`GitHub Release ${tag} did not appear within ${timeoutMs}ms`);
204
- }
205
-
206
141
  function githubAccount(root: string): {
207
142
  hostname: string;
208
143
  owner: string;
@@ -323,12 +258,11 @@ program
323
258
  .option("--no-validate", "skip repository validation tasks, Rust tests, and TypeScript compile")
324
259
  .option("--no-local-publish", "skip local npm, PyPI, and Cargo publication")
325
260
  .option("--no-local-cargo", "skip local Cargo publication")
326
- .option("--build <mode>", "native release build: auto, local, or remote", "auto")
327
261
  .option("--no-approve", "open the release pull request without enabling automatic merge")
328
262
  .option("--no-wait", "return after enabling automatic merge without watching publication")
329
263
  .option(
330
264
  "--wait-timeout-minutes <minutes>",
331
- "maximum time for pull request checks, merge, and publication",
265
+ "maximum time for pull request checks and merge",
332
266
  "120",
333
267
  )
334
268
  .action(
@@ -348,7 +282,6 @@ program
348
282
  validate: boolean;
349
283
  localPublish: boolean;
350
284
  localCargo: boolean;
351
- build: ReleaseBuildMode;
352
285
  approve: boolean;
353
286
  wait: boolean;
354
287
  waitTimeoutMinutes: string;
@@ -367,29 +300,21 @@ program
367
300
  throw new Error("--wait-timeout-minutes must be a positive integer");
368
301
  }
369
302
  const waitTimeoutMs = waitTimeoutMinutes * 60_000;
303
+ if (opts.approve && !opts.wait) {
304
+ throw new Error("automatic merge requires waiting so the draft candidate can be built");
305
+ }
370
306
  const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
371
307
  if (!currentBranch) throw new Error("Release preparation requires a local branch");
372
308
  const account = githubAccount(root);
373
- if (!["auto", "local", "remote"].includes(opts.build)) {
374
- throw new Error("--build must be auto, local, or remote");
309
+ if (!account.canManage) {
310
+ throw new Error("Release preparation requires maintain or admin permission");
311
+ }
312
+ if (hasLocalReleaseTargets(root)) {
313
+ const localTools = missingLocalReleaseTools(root);
314
+ if (localTools.length > 0) {
315
+ throw new Error(`Local release candidate build requires: ${localTools.join(", ")}`);
316
+ }
375
317
  }
376
- const inspectLocalTools =
377
- opts.build === "local" || (opts.build === "auto" && account.canManage);
378
- const localTools = inspectLocalTools ? missingLocalReleaseTools(root) : [];
379
- const buildMode = resolveReleaseBuildMode({
380
- requested: opts.build,
381
- canManage: account.canManage,
382
- localTools,
383
- hasLocalTargets: hasLocalReleaseTargets(root),
384
- approve: opts.approve,
385
- wait: opts.wait,
386
- });
387
- logger.info("selected native release build", {
388
- requested: opts.build,
389
- resolved: buildMode,
390
- manager: account.canManage,
391
- ...(localTools.length > 0 ? { missing: localTools } : {}),
392
- });
393
318
  git(root, ["fetch", "--tags", "origin", opts.base]);
394
319
  const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
395
320
  fetch: false,
@@ -529,13 +454,7 @@ program
529
454
  git(releaseRoot, ["add", "-A"]);
530
455
  const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
531
456
  if (staged) {
532
- git(releaseRoot, [
533
- "commit",
534
- "-m",
535
- `chore(release): ${next.version}`,
536
- "-m",
537
- `Release-Build: ${buildMode}`,
538
- ]);
457
+ git(releaseRoot, ["commit", "-m", `chore(release): ${next.version}`]);
539
458
  } else if (!worktreeExists) {
540
459
  throw new Error("Release preparation produced no changes");
541
460
  }
@@ -550,9 +469,7 @@ program
550
469
  "",
551
470
  `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
552
471
  "",
553
- "Merging this PR updates VERSION on main and starts the public release workflow.",
554
- "",
555
- `Native build: ${buildMode}.`,
472
+ "Merging this PR updates VERSION on main and prepares a draft GitHub Release.",
556
473
  ...(releaseSummary ? ["", releaseSummary] : []),
557
474
  ].join("\n");
558
475
  const githubEnvironment = {
@@ -601,35 +518,35 @@ program
601
518
  waitTimeoutMs,
602
519
  );
603
520
  logger.info("release pull request merged", { releaseBranch, mergeSha });
604
- if (buildMode === "local") {
605
- git(root, ["fetch", "origin", opts.base]);
606
- git(releaseRoot, ["switch", "--detach", mergeSha]);
607
- runTaskCommand(releaseRoot, process.execPath, ["install"]);
608
- await waitForGitHubRelease(root, releaseTag, githubEnvironment, waitTimeoutMs);
609
- const releaseAssetsScript = join(
610
- releaseRoot,
611
- "node_modules/@dbx-tools/projen/tasks/release-assets.ts",
612
- );
613
- runTaskCommand(
521
+ git(root, ["fetch", "origin", opts.base]);
522
+ git(releaseRoot, ["switch", "--detach", mergeSha]);
523
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
524
+ const releaseCandidateScript = join(
525
+ releaseRoot,
526
+ "node_modules/@dbx-tools/projen/tasks/release-candidate.ts",
527
+ );
528
+ runTaskCommand(
529
+ releaseRoot,
530
+ process.execPath,
531
+ [
532
+ releaseCandidateScript,
533
+ "--root",
614
534
  releaseRoot,
615
- process.execPath,
616
- [
617
- releaseAssetsScript,
618
- "--root",
619
- releaseRoot,
620
- "--version",
621
- next.version,
622
- "--tag",
623
- releaseTag,
624
- "--upload",
625
- ],
626
- { env: githubEnvironment },
627
- );
628
- }
535
+ "--version",
536
+ next.version,
537
+ "--tag",
538
+ releaseTag,
539
+ "--sha",
540
+ mergeSha,
541
+ "--notes-file",
542
+ `docs/releases/v${next.version}.md`,
543
+ "--upload",
544
+ ],
545
+ { env: githubEnvironment },
546
+ );
629
547
  git(root, ["worktree", "remove", "--force", releaseRoot]);
630
548
  git(root, ["branch", "--delete", "--force", releaseBranch]);
631
- await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
632
- logger.success(`published ${releaseTag} from ${mergeSha}`);
549
+ logger.success(`prepared draft ${releaseTag} from ${mergeSha}`);
633
550
  return;
634
551
  }
635
552
  git(root, ["worktree", "remove", "--force", releaseRoot]);