-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathrelease.js
More file actions
505 lines (443 loc) Β· 18 KB
/
Copy pathrelease.js
File metadata and controls
505 lines (443 loc) Β· 18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
#!/usr/bin/env node
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const readline = require("node:readline/promises");
// ---------------------------------------------------------------------------
// Arguments
//
// node release.js # patch bump (1.0.11 -> 1.0.12)
// node release.js --minor # minor bump (1.0.11 -> 1.1.0)
// node release.js --major # major bump (1.0.11 -> 2.0.0)
// node release.js 2.0.0-rc.1 # explicit version (overrides bump flag)
// node release.js --minor --dry # validate without publishing
// node release.js --yes # skip the confirmation prompt
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const dryRun = args.includes("--dry");
const skipConfirm = args.includes("--yes") || args.includes("-y");
const bumpKind = args.includes("--major")
? "major"
: args.includes("--minor")
? "minor"
: "patch";
// First non-flag argument is treated as an explicit version override.
const explicitVersion = args.find((arg) => !arg.startsWith("-"));
if (explicitVersion && !/^\d+\.\d+\.\d+(-.*)?$/.test(explicitVersion)) {
console.error(
"Invalid version format. Use semantic versioning (e.g., 1.2.3 or 1.2.3-beta.1)"
);
process.exit(1);
}
// Cached GitHub owner/repo parsed from the origin remote.
let repoInfo = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Run a shell command. `dryRun: true` makes it a no-op (logged) during --dry.
// `allowFailure: true` returns null instead of exiting on a non-zero exit.
function run(command, options = {}) {
try {
if (options.dryRun && dryRun) {
console.log(` [DRY RUN] Would execute: ${command}`);
return "";
}
return execSync(command, { encoding: "utf8", ...options }).trim();
} catch (error) {
if (!options.allowFailure) {
console.error(`Command failed: ${command}`);
console.error(error.message);
process.exit(1);
}
return null;
}
}
// Stream a command's output straight to the terminal (for long-running,
// chatty commands like `cargo ws publish` where live progress matters).
function runLive(command, options = {}) {
if (options.dryRun && dryRun) {
console.log(` [DRY RUN] Would execute: ${command}`);
return;
}
execSync(command, { stdio: "inherit", ...options });
}
function getRepoInfo() {
if (repoInfo) return repoInfo;
const remoteUrl = run("git remote get-url origin", { allowFailure: true });
const match = remoteUrl && remoteUrl.match(/github\.com[:/]([^/]+)\/([^.]+)/);
repoInfo = match ? { owner: match[1], repo: match[2] } : null;
return repoInfo;
}
// Read the current workspace version. All crates inherit it via
// `version.workspace = true`, so the canonical source is the root
// Cargo.toml's [workspace.package] section.
function currentVersion() {
const m = fs
.readFileSync("Cargo.toml", "utf8")
.match(/^\[workspace\.package\][\s\S]*?^version = "(.*)"$/m);
if (!m) {
console.error(
"β Could not read current version from [workspace.package] in Cargo.toml"
);
process.exit(1);
}
return m[1];
}
// Compute the next version from a bump keyword. Drops any pre-release suffix.
function nextVersion(cur, kind) {
const [maj, min, pat] = cur.split("-")[0].split(".").map(Number);
if (kind === "major") return `${maj + 1}.0.0`;
if (kind === "minor") return `${maj}.${min + 1}.0`;
return `${maj}.${min}.${pat + 1}`;
}
function checkCargoWs() {
const wsVersion = run("cargo ws --version", { allowFailure: true });
if (!wsVersion) {
console.error(
"β cargo-workspaces not found. Install it with:\n cargo install cargo-workspaces"
);
process.exit(1);
}
console.log(`β
${wsVersion}`);
}
function checkGitStatus() {
console.log("π Checking git status...");
const status = run("git status --porcelain");
if (status && !dryRun) {
console.error(
"β Git working directory is not clean. Please commit or stash your changes."
);
console.error("Uncommitted changes:");
console.error(status);
process.exit(1);
} else if (status && dryRun) {
console.warn("β οΈ Git working directory is not clean (ignored in dry run)");
}
const branch = run("git rev-parse --abbrev-ref HEAD");
console.log(`β
Git is clean on branch: ${branch}`);
console.log("π Fetching latest from origin...");
run("git fetch origin master");
const behind = run("git rev-list HEAD..origin/master --count");
if (behind !== "0") {
if (!dryRun) {
console.error(
`β Branch is ${behind} commits behind origin/master. Please pull latest changes.`
);
process.exit(1);
}
console.warn(
`β οΈ Branch is ${behind} commits behind origin/master (ignored in dry run)`
);
} else {
console.log("β
Branch is up to date with origin/master");
}
}
async function checkGitHubActions() {
console.log("π Checking GitHub Actions status...");
try {
const info = getRepoInfo();
if (!info) {
console.warn("β οΈ Could not parse GitHub repository from remote URL");
return;
}
console.log(`Repository: ${info.owner}/${info.repo}`);
const ghVersion = run("gh --version", { allowFailure: true });
if (!ghVersion) {
console.warn("β οΈ GitHub CLI (gh) not found. Skipping workflow check.");
console.warn(" Install with: brew install gh");
return;
}
const workflowRuns = run(
`gh run list --branch master --limit 1 --json status,conclusion,headSha`
);
const runs = JSON.parse(workflowRuns);
if (runs.length === 0) {
console.warn("β οΈ No workflow runs found on master branch");
return;
}
const latestRun = runs[0];
// Gate on the master commit we're releasing from (origin/master), not local
// HEAD β local HEAD is the release branch tip on a resume and has no run.
const targetSha = run("git rev-parse origin/master");
console.log(`Latest workflow SHA: ${latestRun.headSha.substring(0, 7)}`);
// Make sure the run we're inspecting is actually for that commit, otherwise
// a stale green run from an older commit would pass the gate.
if (latestRun.headSha !== targetSha) {
const msg = `Latest workflow ran against ${latestRun.headSha.substring(
0,
7
)}, not origin/master ${targetSha.substring(0, 7)}.`;
if (!dryRun) {
console.error(`β ${msg} Wait for CI to run on the latest commit.`);
process.exit(1);
}
console.warn(`β οΈ ${msg} (ignored in dry run)`);
return;
}
if (latestRun.status === "completed" && latestRun.conclusion === "success") {
console.log("β
Latest GitHub Actions workflow succeeded");
} else if (latestRun.status === "in_progress") {
if (!dryRun) {
console.error(
"β GitHub Actions workflow is still in progress. Please wait for it to complete."
);
process.exit(1);
}
console.warn(
"β οΈ GitHub Actions workflow is still in progress (ignored in dry run)"
);
} else {
if (!dryRun) {
console.error(
`β Latest GitHub Actions workflow failed with status: ${latestRun.conclusion}`
);
process.exit(1);
}
console.warn(
`β οΈ Latest GitHub Actions workflow failed with status: ${latestRun.conclusion} (ignored in dry run)`
);
}
} catch (error) {
console.warn("β οΈ Could not check GitHub Actions status:", error.message);
}
}
// Generate release notes for `version` into releases/<version>-RELEASE.md.
// Returns the notes content (used for the GitHub release body).
function generateReleaseNotes(version) {
console.log("\nπ Generating release notes...");
const releaseDir = "./releases";
const releaseFile = path.join(releaseDir, `${version}-RELEASE.md`);
if (fs.existsSync(releaseFile)) {
console.log(`β
Release notes already exist at ${releaseFile}`);
return fs.readFileSync(releaseFile, "utf8");
}
if (!fs.existsSync(releaseDir) && !dryRun) {
fs.mkdirSync(releaseDir, { recursive: true });
}
const latestTag =
run("git describe --tags --abbrev=0", { allowFailure: true }) || "";
console.log(` Latest tag: ${latestTag || "none"}`);
const commits = latestTag
? run(`git log ${latestTag}..HEAD --oneline`)
: run("git log --oneline -20");
const gitDiff = latestTag ? run(`git diff ${latestTag}..HEAD --stat`) : "";
const prompt = `Generate professional release notes for version ${version} of the DLC DevKit (DDK) Rust workspace.
Here are the commits since the last release (${latestTag || "initial release"}):
${commits}
File changes summary:
${gitDiff}
The workspace contains these crates that are all being released with version ${version}:
- ddk-trie: Trie data structure for DLC
- ddk-messages: DLC message protocol implementation
- kormir: Oracle implementation
- ddk-dlc: Core DLC functionality
- ddk-manager: DLC management and coordination
- ddk: Main DLC DevKit library
- ddk-payouts: Payout calculation utilities
- ddk-node: DLC node implementation
Please create release notes with:
1. A brief summary of the release
2. Breaking changes (if any, look for BREAKING in commits or major API changes)
3. New features (commits starting with feat:)
4. Bug fixes (commits starting with fix:)
5. Other notable changes
6. Installation instructions showing how to add ddk = "${version}" to Cargo.toml
Format as clean markdown suitable for a GitHub release. Be concise but informative.`;
if (dryRun) {
console.log(" [DRY RUN] Would generate release notes using Claude");
return `# Release v${version}\n\n[DRY RUN - notes generated here]\n`;
}
try {
console.log(" Using Claude to generate release notes...");
const tempPromptFile = `/tmp/release-prompt-${version}.txt`;
fs.writeFileSync(tempPromptFile, prompt);
const claudeOutput = run(`claude -p "$(cat ${tempPromptFile})"`, {
allowFailure: true,
timeout: 60000,
});
fs.unlinkSync(tempPromptFile);
if (!claudeOutput) throw new Error("Claude returned no output");
fs.writeFileSync(releaseFile, claudeOutput);
console.log(`β
Release notes written to ${releaseFile}`);
return claudeOutput;
} catch (error) {
console.warn(
`β οΈ Claude release notes failed (${error.message}); using basic template.`
);
let content = `# Release v${version}\n\n`;
content += `Released: ${new Date().toISOString().split("T")[0]}\n\n`;
content += `## π₯ Installation\n\n\`\`\`toml\nddk = "${version}"\n\`\`\`\n\n`;
content += `## Commits\n\n\`\`\`\n${commits}\n\`\`\`\n`;
fs.writeFileSync(releaseFile, content);
console.log(`β
Basic release notes written to ${releaseFile}`);
return content;
}
}
async function createGitHubRelease(version, releaseNotes) {
console.log("\nπ Creating GitHub release...");
const ghVersion = run("gh --version", { allowFailure: true });
if (!ghVersion) {
console.warn("β οΈ GitHub CLI (gh) not found. Skipping GitHub release.");
return;
}
const info = getRepoInfo();
const slug = info ? `${info.owner}/${info.repo}` : "<owner>/<repo>";
try {
const tempFile = `/tmp/release-notes-${version}.md`;
fs.writeFileSync(tempFile, releaseNotes);
run(
`gh release create v${version} --title "v${version}" --notes-file ${tempFile}`
);
fs.unlinkSync(tempFile);
console.log(`β
GitHub release v${version} created`);
console.log(` View at: https://github.com/${slug}/releases/tag/v${version}`);
} catch (error) {
console.warn(`β οΈ Failed to create GitHub release: ${error.message}`);
console.log(` Create it manually: https://github.com/${slug}/releases/new`);
}
}
// ---------------------------------------------------------------------------
// Main release process
// ---------------------------------------------------------------------------
async function release() {
// Step 0: tooling + working-tree preflight (cheap, fail fast).
checkCargoWs();
const cur = currentVersion();
const version = explicitVersion || nextVersion(cur, bumpKind);
const releaseBranch = `release-${version}`;
console.log(
`\nπ Release: ${cur} β ${version} (${
explicitVersion ? "explicit" : bumpKind
})${dryRun ? " β DRY RUN" : ""}\n`
);
// Step 1: git + CI gates.
checkGitStatus();
await checkGitHubActions();
// Step 2: confirm before doing anything irreversible.
if (!dryRun && !skipConfirm) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(
`\nβ Publish all crates as v${version} to crates.io? [y/N] `
);
rl.close();
if (answer.trim().toLowerCase() !== "y") {
console.log("Aborted.");
process.exit(0);
}
}
// Step 3: dry run validates the whole pipeline without mutating anything.
if (dryRun) {
console.log("\nπ Generating release notes (preview)...");
generateReleaseNotes(version);
console.log("\nπ¦ Validating publish via cargo-workspaces (--dry-run)...");
runLive(
`cargo ws publish custom ${version} --force '*' --allow-branch '*' ` +
`--no-git-tag --no-git-push --dry-run --allow-dirty -y`
);
console.log("\nπ Dry run complete. To perform the real release:");
console.log(
` node release.js ${explicitVersion ? version : `--${bumpKind}`}`
);
return;
}
// Step 4: create (or resume) the release branch.
const branchExists = run(`git rev-parse --verify ${releaseBranch} 2>/dev/null`, {
allowFailure: true,
});
if (branchExists) {
run(`git checkout ${releaseBranch}`);
console.log(`β
Reusing existing branch ${releaseBranch}`);
} else {
run(`git checkout -b ${releaseBranch}`);
console.log(`β
Created branch ${releaseBranch}`);
}
// Step 5: generate release notes. The `releases/` dir is gitignored β the
// notes live on the GitHub release page (see Step 10), not in the repo β so
// there's nothing to commit, and the ignored file doesn't dirty the working
// tree before cargo-workspaces runs.
const releaseNotes = generateReleaseNotes(version);
// Step 6: bump + publish in dependency order via cargo-workspaces.
// It derives the publish order from the dependency graph and skips crates
// already on crates.io, so a re-run safely resumes a partial publish.
//
// If the branch is already bumped (resume after a mid-publish failure),
// publish the existing versions as-is instead of re-versioning.
const alreadyBumped = currentVersion() === version;
console.log("\nπ¦ Publishing crates to crates.io via cargo-workspaces...");
if (alreadyBumped) {
console.log(` (versions already at ${version} β publishing as-is)`);
runLive(
`cargo ws publish --publish-as-is --allow-branch 'release-*' ` +
`--no-git-tag --no-git-push -y`
);
} else {
runLive(
`cargo ws publish custom ${version} --force '*' ` +
`--allow-branch 'release-*' --no-git-tag --no-git-push -y ` +
`-m "chore: release v%v"`
);
}
console.log("β
All crates published");
// Step 7: tag the release commit (cargo ws tagging is disabled above so this
// is the single source of truth and is idempotent across resumes).
run(`git tag -a v${version} -m "Release v${version}"`, {
allowFailure: true,
});
// Step 8: push branch + tag.
console.log("\nπ€ Pushing release branch and tag to origin...");
run(`git push -u origin ${releaseBranch}`);
run(`git push origin v${version}`);
console.log("β
Branch and tag pushed");
// Step 9: open the release PR.
const info = getRepoInfo();
const slug = info ? `${info.owner}/${info.repo}` : "<owner>/<repo>";
console.log("π Creating pull request...");
const prBodyFile = `/tmp/release-pr-body-${version}.md`;
fs.writeFileSync(
prBodyFile,
`Release version ${version}\n\n## Changes\n- Bumped all crate versions to ${version}\n- Published crates to crates.io\n\n## Release Notes\nSee releases/${version}-RELEASE.md\n`
);
try {
const prUrl = run(
`gh pr create --title "chore: release ${version}" --body-file ${prBodyFile} --base master --head ${releaseBranch}`
);
console.log(`β
Pull request created: ${prUrl}`);
} catch (error) {
console.warn("β οΈ Could not create PR automatically:", error.message);
console.log(
` Create it manually: https://github.com/${slug}/compare/master...${releaseBranch}`
);
}
fs.unlinkSync(prBodyFile);
// Step 10: GitHub release.
await createGitHubRelease(version, releaseNotes);
// Step 11: back to master. cargo's per-crate publish verification re-resolves
// dependencies and can rewrite Cargo.lock with incidental transitive drift
// (e.g. a patch bump of a transitive dep) *after* cargo-workspaces made the
// release commit. That dirties the working tree and would abort the branch
// switch. The drift isn't part of the release β the published crates and the
// tag don't include it, and it re-resolves on the next build β so discard it.
// Everything important is already published/pushed, so keep this cleanup
// best-effort rather than failing the whole run on it.
run("git checkout -- Cargo.lock", { allowFailure: true });
if (run("git checkout master", { allowFailure: true }) === null) {
console.warn(
"β οΈ Could not switch back to master β the working tree still has local " +
"changes. The release itself is complete; run `git checkout master` " +
"manually after handling them."
);
}
console.log("\nπ Release complete!");
console.log(" - All crates published to crates.io");
console.log(` - Tag v${version} created and pushed`);
console.log(" - Release PR opened");
console.log(" - GitHub release created");
console.log("\nβ οΈ Next step: review and merge the release PR.");
}
release().catch((error) => {
console.error("β Release failed:", error);
process.exit(1);
});