From d1887110865f4294808a0aa56b60e7369dc85287 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Tue, 4 Aug 2026 10:07:48 -0400 Subject: [PATCH] fix(ci): allow rc releases --- Cargo.lock | 54 ++++++--------- release.js | 190 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 189 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20aee29..d4e0730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,9 +1226,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1236,12 +1236,11 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ "defmt-parser", - "proc-macro-error2", "proc-macro2", "quote", "syn", @@ -2270,11 +2269,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.29" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -2282,12 +2282,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.29" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn", @@ -3184,9 +3194,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -3298,28 +3308,6 @@ dependencies = [ "syn", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" diff --git a/release.js b/release.js index b62ec8b..0d90844 100644 --- a/release.js +++ b/release.js @@ -14,6 +14,11 @@ const readline = require("node:readline/promises"); // 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 +// +// Pre-releases go through the explicit-version form. crates.io treats them as +// opt-in (a `2.0` requirement never resolves to `2.0.0-rc.1`), the GitHub +// release is flagged as a prerelease, and a later `node release.js` finalizes +// it to the base version (2.0.0-rc.1 -> 2.0.0) rather than skipping past it. // --------------------------------------------------------------------------- const args = process.argv.slice(2); const dryRun = args.includes("--dry"); @@ -93,12 +98,72 @@ function currentVersion() { return m[1]; } -// Compute the next version from a bump keyword. Drops any pre-release suffix. +// Compute the next version from a bump keyword. A patch bump off a pre-release +// finalizes it (2.0.0-rc.1 -> 2.0.0) instead of stepping past the version the +// release candidate was a candidate *for*. function nextVersion(cur, kind) { - const [maj, min, pat] = cur.split("-")[0].split(".").map(Number); + const base = cur.split("-")[0]; + const [maj, min, pat] = base.split(".").map(Number); if (kind === "major") return `${maj + 1}.0.0`; if (kind === "minor") return `${maj}.${min + 1}.0`; - return `${maj}.${min}.${pat + 1}`; + return cur.includes("-") ? base : `${maj}.${min}.${pat + 1}`; +} + +// Write `version` into [workspace.package]. Only the dry run calls this — it +// stages the release state in the working tree and restores it afterwards. The +// real run lets `cargo ws version` do the bump so Cargo.lock gets refreshed too. +function setWorkspaceVersion(version) { + const content = fs.readFileSync("Cargo.toml", "utf8"); + fs.writeFileSync( + "Cargo.toml", + content.replace( + /^(\[workspace\.package\][\s\S]*?^version = )".*"$/m, + `$1"${version}"` + ) + ); +} + +// Re-pin the workspace-internal crates in [workspace.dependencies] to `version`. +// +// cargo-workspaces bumps [workspace.package] version but never touches +// [workspace.dependencies]: every intra-workspace dep is declared +// `workspace = true`, so there is nothing in the member manifests for it to +// rewrite. The published manifests carry whatever requirement was last written +// by hand. +// +// That is benign for a stable release — an older caret range still resolves to +// the new version — but it silently breaks pre-releases, because `^1.0.11` +// never matches `2.0.0-rc.1`. Without this, an rc of `ddk` would depend on the +// last *stable* ddk-manager rather than the rc published alongside it. +// +// Returns true if anything changed. +function pinWorkspaceDeps(version) { + let inSection = false; + let changed = 0; + + const updated = fs + .readFileSync("Cargo.toml", "utf8") + .split("\n") + .map((line) => { + if (line.startsWith("[")) { + inSection = line.trim() === "[workspace.dependencies]"; + return line; + } + // Only the workspace's own crates carry a `path`; third-party pins stay. + if (!inSection || !/\bpath\s*=/.test(line)) return line; + const pinned = line.replace( + /\bversion\s*=\s*"[^"]*"/, + `version = "${version}"` + ); + if (pinned !== line) changed++; + return pinned; + }) + .join("\n"); + + if (!changed) return false; + fs.writeFileSync("Cargo.toml", updated); + console.log(` Pinned ${changed} workspace dependencies to ${version}`); + return true; } function checkCargoWs() { @@ -278,7 +343,12 @@ Please create release notes with: 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.`; +Format as clean markdown suitable for a GitHub release. Be concise but informative. + +Output the release notes and nothing else: the very first line must be the +top-level heading, and the last line must be the last line of the notes. No +preamble, no commentary addressed to whoever ran this, no trailing questions — +the output is written verbatim into the GitHub release body.`; if (dryRun) { console.log(" [DRY RUN] Would generate release notes using Claude"); @@ -286,14 +356,21 @@ Format as clean markdown suitable for a GitHub release. Be concise but informati } try { - console.log(" Using Claude to generate release notes..."); + // Measured at ~130s for a 12-commit range, so the ceiling is generous: the + // cost of waiting is a slow release, the cost of timing out is silently + // shipping the bare commit-list fallback below. + console.log(" Using Claude to generate release notes (up to 5 min)..."); 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); + let claudeOutput; + try { + claudeOutput = run(`claude -p "$(cat ${tempPromptFile})"`, { + allowFailure: true, + timeout: 300000, + }); + } finally { + fs.unlinkSync(tempPromptFile); + } if (!claudeOutput) throw new Error("Claude returned no output"); @@ -314,6 +391,50 @@ Format as clean markdown suitable for a GitHub release. Be concise but informati } } +// Confirm every publishable crate actually landed on crates.io. +// +// cargo-workspaces reports a crate it couldn't publish as `warn publish failed +// ` and still exits 0 with `info success ok`. Without this check a +// partial publish would sail straight into tagging, pushing, and cutting a +// GitHub release for a version that isn't fully on the registry. +function verifyPublished(version) { + const meta = JSON.parse(run("cargo metadata --no-deps --format-version 1")); + // `publish` is null when unrestricted and [] for `publish = false`. + const crates = meta.packages + .filter((p) => p.publish === null || p.publish.length > 0) + .map((p) => p.name) + .sort(); + + console.log(`\nšŸ”Ž Verifying ${crates.length} crates on crates.io...`); + const missing = []; + for (const name of crates) { + let found = false; + // The index lags the upload by a moment, so give each crate a few tries. + for (let attempt = 0; attempt < 5 && !found; attempt++) { + if (attempt > 0) run("sleep 5"); + // crates.io rejects requests without a User-Agent with a 403. + const code = run( + `curl -s -A 'dlcdevkit-release-script' -o /dev/null -w '%{http_code}' ` + + `https://crates.io/api/v1/crates/${name}/${version}`, + { allowFailure: true } + ); + found = code === "200"; + } + console.log(` ${found ? "āœ…" : "āŒ"} ${name} ${version}`); + if (!found) missing.push(name); + } + + if (missing.length) { + console.error( + `\nāŒ Not on crates.io: ${missing.join(", ")}\n` + + ` Stopping before the tag, push, and GitHub release. Re-run the same\n` + + ` command to resume — cargo-workspaces skips crates already published.` + ); + process.exit(1); + } + console.log("āœ… All crates verified on crates.io"); +} + async function createGitHubRelease(version, releaseNotes) { console.log("\nšŸš€ Creating GitHub release..."); @@ -329,8 +450,10 @@ async function createGitHubRelease(version, releaseNotes) { try { const tempFile = `/tmp/release-notes-${version}.md`; fs.writeFileSync(tempFile, releaseNotes); + // Pre-releases must not displace the last stable release as "Latest". + const prerelease = version.includes("-") ? " --prerelease" : ""; run( - `gh release create v${version} --title "v${version}" --notes-file ${tempFile}` + `gh release create v${version} --title "v${version}"${prerelease} --notes-file ${tempFile}` ); fs.unlinkSync(tempFile); console.log(`āœ… GitHub release v${version} created`); @@ -384,10 +507,21 @@ async function release() { 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` - ); + // Stage the exact manifest state the real run publishes — bumped version + // *and* re-pinned intra-workspace deps — so the dry run validates what will + // actually go to crates.io, then put Cargo.toml back byte-for-byte. + const original = fs.readFileSync("Cargo.toml", "utf8"); + try { + setWorkspaceVersion(version); + pinWorkspaceDeps(version); + runLive( + `cargo ws publish --publish-as-is --allow-branch '*' ` + + `--no-git-tag --no-git-push --dry-run --allow-dirty -y` + ); + } finally { + fs.writeFileSync("Cargo.toml", original); + console.log(" Restored Cargo.toml (Cargo.lock may have been refreshed)"); + } console.log("\nšŸŽ‰ Dry run complete. To perform the real release:"); console.log( @@ -421,21 +555,33 @@ async function release() { // 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` - ); + console.log(`\nšŸ“¦ Versions already at ${version} — skipping the bump`); } else { + console.log("\nšŸ“¦ Bumping crate versions via cargo-workspaces..."); runLive( - `cargo ws publish custom ${version} --force '*' ` + + `cargo ws version custom ${version} --force '*' ` + `--allow-branch 'release-*' --no-git-tag --no-git-push -y ` + `-m "chore: release v%v"` ); } + + // Pin [workspace.dependencies] to the version just bumped to. This has to + // happen *after* the bump — pinning first would leave the manifest asking for + // a version the local path crates don't yet have, which `cargo metadata` + // refuses to resolve. Idempotent, so a resumed run is a no-op here. + if (pinWorkspaceDeps(version)) { + run(`git commit -m "chore: pin workspace deps to v${version}" -- Cargo.toml`); + console.log("āœ… Workspace dependency pins committed"); + } + + console.log("\nšŸ“¦ Publishing crates to crates.io via cargo-workspaces..."); + runLive( + `cargo ws publish --publish-as-is --allow-branch 'release-*' ` + + `--no-git-tag --no-git-push -y` + ); console.log("āœ… All crates published"); + verifyPublished(version); // 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).