From 8c5151cbdfde7605df2c7f1d14cb916ebe3e9cec Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:08:20 -0700 Subject: [PATCH 1/5] feat: automate Rust crate versioning in publish-ci When 'npm run publish-ci' runs, it now also: - Inspects git commits touching crates/microsoft-fast-build/ since the last 'microsoft-fast-build-vX.Y.Z' tag using conventional commit rules - Bumps the version in crates/microsoft-fast-build/Cargo.toml - Packages the crate via 'cargo package' and copies the .crate file to publish_artifacts_cargo/ If no relevant commits are found the script exits cleanly with no changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/publish-rust.mjs | 113 +++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 build/publish-rust.mjs diff --git a/build/publish-rust.mjs b/build/publish-rust.mjs new file mode 100644 index 00000000000..614b05e9cc0 --- /dev/null +++ b/build/publish-rust.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Bumps the Rust crate version based on conventional commits since the last + * release tag, then packages the crate into publish_artifacts_cargo/. + * + * Version bump rules (conventional commits): + * BREAKING CHANGE / feat!: / fix!: → major + * feat: → minor + * anything else → patch + * + * Only commits that touch crates/microsoft-fast-build/ are considered. + * If no such commits exist since the last tag, the script exits without change. + */ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync, copyFileSync, globSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); +const crateDir = join(repoRoot, "crates", "microsoft-fast-build"); +const cargoTomlPath = join(crateDir, "Cargo.toml"); +const outputDir = join(repoRoot, "publish_artifacts_cargo"); + +function getCurrentVersion() { + const content = readFileSync(cargoTomlPath, "utf-8"); + const match = content.match(/^version\s*=\s*"([^"]+)"/m); + if (!match) throw new Error("Could not find version in Cargo.toml"); + return match[1]; +} + +function parseVersion(version) { + const [major, minor, patch] = version.split(".").map(Number); + return { major, minor, patch }; +} + +function getCommitsSinceCrateLastTag(crateName, currentVersion) { + const tagName = `${crateName}-v${currentVersion}`; + let fromRef; + try { + execSync(`git -C "${repoRoot}" rev-parse "${tagName}"`, { stdio: "pipe" }); + fromRef = tagName; + } catch { + fromRef = null; + } + + const range = fromRef ? `${fromRef}..HEAD` : "HEAD"; + const log = execSync( + `git -C "${repoRoot}" log ${range} --pretty=format:"%s%n%b" -- crates/microsoft-fast-build/`, + { encoding: "utf-8" } + ); + return log.trim(); +} + +function determineBump(commits) { + if (!commits) return null; + + if (/BREAKING CHANGE/m.test(commits) || /^(feat|fix|refactor|chore)!(\([^)]*\))?:/m.test(commits)) { + return "major"; + } + if (/^feat(\([^)]*\))?:/m.test(commits)) { + return "minor"; + } + return "patch"; +} + +function bumpVersion(version, bump) { + const { major, minor, patch } = parseVersion(version); + switch (bump) { + case "major": return `${major + 1}.0.0`; + case "minor": return `${major}.${minor + 1}.0`; + case "patch": return `${major}.${minor}.${patch + 1}`; + } +} + +function updateCargoToml(newVersion) { + let content = readFileSync(cargoTomlPath, "utf-8"); + content = content.replace(/^(version\s*=\s*)"[^"]+"/m, `$1"${newVersion}"`); + writeFileSync(cargoTomlPath, content); +} + +function packageCrate(version) { + mkdirSync(outputDir, { recursive: true }); + execSync( + `cargo package --manifest-path "${cargoTomlPath}" --no-verify --allow-dirty`, + { stdio: "inherit" } + ); + const targetPackageDir = join(crateDir, "target", "package"); + const crateFiles = globSync(`microsoft-fast-build-${version}.crate`, { cwd: targetPackageDir }); + if (crateFiles.length === 0) { + throw new Error(`Could not find packaged .crate file in ${targetPackageDir}`); + } + for (const file of crateFiles) { + copyFileSync(join(targetPackageDir, file), join(outputDir, file)); + console.log(`Packaged ${file} → ${outputDir}`); + } +} + +const currentVersion = getCurrentVersion(); +const commits = getCommitsSinceCrateLastTag("microsoft-fast-build", currentVersion); +const bump = determineBump(commits); + +if (!bump) { + console.log("No changes to Rust crate since last release, skipping."); + process.exit(0); +} + +const newVersion = bumpVersion(currentVersion, bump); +console.log(`Bumping microsoft-fast-build: ${currentVersion} → ${newVersion} (${bump})`); + +updateCargoToml(newVersion); +packageCrate(newVersion); +console.log(`Rust crate microsoft-fast-build@${newVersion} packaged to ${outputDir}/`); diff --git a/package.json b/package.json index ca498bd7850..16b51d023c8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "check": "beachball check ", "build:gh-pages": "npm run build -w @microsoft/fast-site", "publish": "beachball publish", - "publish-ci": "beachball publish -y --no-publish", + "publish-ci": "beachball publish -y --no-publish && node build/publish-rust.mjs", "sync": "beachball sync", "test:diff:error": "echo \"Untracked files exist, try running npm prepare to identify the culprit.\" && exit 1", "test:diff": "git update-index --refresh && git diff-index --quiet HEAD -- || npm run test:diff:error", From 6de03a92a1813dc3857ad5cfd520152754447c95 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:17:50 -0700 Subject: [PATCH 2/5] fix: use Beachball tag format {name}_v{version} for Rust crate release detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/publish-rust.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/publish-rust.mjs b/build/publish-rust.mjs index 614b05e9cc0..b5b3f613226 100644 --- a/build/publish-rust.mjs +++ b/build/publish-rust.mjs @@ -35,7 +35,7 @@ function parseVersion(version) { } function getCommitsSinceCrateLastTag(crateName, currentVersion) { - const tagName = `${crateName}-v${currentVersion}`; + const tagName = `${crateName}_v${currentVersion}`; let fromRef; try { execSync(`git -C "${repoRoot}" rev-parse "${tagName}"`, { stdio: "pipe" }); From 37987c35e76d7a08b40fff77bf419b0707b72aa9 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:19:08 -0700 Subject: [PATCH 3/5] docs: clarify that release tags in publish-rust.mjs are generated by Beachball Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/publish-rust.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/build/publish-rust.mjs b/build/publish-rust.mjs index b5b3f613226..8469e1bf5fe 100644 --- a/build/publish-rust.mjs +++ b/build/publish-rust.mjs @@ -1,7 +1,10 @@ #!/usr/bin/env node /** * Bumps the Rust crate version based on conventional commits since the last - * release tag, then packages the crate into publish_artifacts_cargo/. + * Beachball-generated release tag, then packages the crate into publish_artifacts_cargo/. + * + * Beachball tags use the format: {package-name}_v{version} + * e.g. microsoft-fast-build_v0.1.0 * * Version bump rules (conventional commits): * BREAKING CHANGE / feat!: / fix!: → major @@ -9,7 +12,7 @@ * anything else → patch * * Only commits that touch crates/microsoft-fast-build/ are considered. - * If no such commits exist since the last tag, the script exits without change. + * If no such commits exist since the last Beachball tag, the script exits without change. */ import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync, copyFileSync, globSync } from "node:fs"; @@ -35,6 +38,7 @@ function parseVersion(version) { } function getCommitsSinceCrateLastTag(crateName, currentVersion) { + // Beachball generates tags in the format {package-name}_v{version} const tagName = `${crateName}_v${currentVersion}`; let fromRef; try { From e7308b02d129082b7b248a6facc01345ad1ed5c0 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:11:38 -0700 Subject: [PATCH 4/5] Swap order of publish steps to take tag creation into account Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 16b51d023c8..6ba6f9a3a8f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "check": "beachball check ", "build:gh-pages": "npm run build -w @microsoft/fast-site", "publish": "beachball publish", - "publish-ci": "beachball publish -y --no-publish && node build/publish-rust.mjs", + "publish-ci": "node build/publish-rust.mjs && beachball publish -y --no-publish", "sync": "beachball sync", "test:diff:error": "echo \"Untracked files exist, try running npm prepare to identify the culprit.\" && exit 1", "test:diff": "git update-index --refresh && git diff-index --quiet HEAD -- || npm run test:diff:error", From ab0755033f1bf8327f605764c4bcf974e1a7feaa Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:23:40 -0700 Subject: [PATCH 5/5] fix: address PR review comments - Use 'git tag --list' to find latest Beachball tag matching the crate instead of constructing a tag name from Cargo.toml version, which can diverge from the actual last-released version - Add publish_artifacts_cargo/ to .gitignore so CI clean-tree checks pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + build/publish-rust.mjs | 28 +++++++++++++--------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 85b932b26ca..78a8c0a553d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ test-results/ # Pack directory publish_artifacts/ +publish_artifacts_cargo/ # Rust build artifacts crates/**/target/ diff --git a/build/publish-rust.mjs b/build/publish-rust.mjs index 8469e1bf5fe..594797ac738 100644 --- a/build/publish-rust.mjs +++ b/build/publish-rust.mjs @@ -7,9 +7,9 @@ * e.g. microsoft-fast-build_v0.1.0 * * Version bump rules (conventional commits): - * BREAKING CHANGE / feat!: / fix!: → major - * feat: → minor - * anything else → patch + * BREAKING CHANGE / feat!: / fix!: / refactor!: / chore!: → major + * feat: → minor + * anything else → patch * * Only commits that touch crates/microsoft-fast-build/ are considered. * If no such commits exist since the last Beachball tag, the script exits without change. @@ -37,18 +37,16 @@ function parseVersion(version) { return { major, minor, patch }; } -function getCommitsSinceCrateLastTag(crateName, currentVersion) { - // Beachball generates tags in the format {package-name}_v{version} - const tagName = `${crateName}_v${currentVersion}`; - let fromRef; - try { - execSync(`git -C "${repoRoot}" rev-parse "${tagName}"`, { stdio: "pipe" }); - fromRef = tagName; - } catch { - fromRef = null; - } +function getCommitsSinceCrateLastTag(crateName) { + // Beachball generates tags in the format {package-name}_v{version}. + // Find the latest matching tag rather than constructing one from the + // current Cargo.toml version, which may diverge from the last release. + const latestTag = execSync( + `git -C "${repoRoot}" tag --list "${crateName}_v*" --sort=-version:refname`, + { encoding: "utf-8" } + ).trim().split("\n")[0] || null; - const range = fromRef ? `${fromRef}..HEAD` : "HEAD"; + const range = latestTag ? `${latestTag}..HEAD` : "HEAD"; const log = execSync( `git -C "${repoRoot}" log ${range} --pretty=format:"%s%n%b" -- crates/microsoft-fast-build/`, { encoding: "utf-8" } @@ -101,7 +99,7 @@ function packageCrate(version) { } const currentVersion = getCurrentVersion(); -const commits = getCommitsSinceCrateLastTag("microsoft-fast-build", currentVersion); +const commits = getCommitsSinceCrateLastTag("microsoft-fast-build"); const bump = determineBump(commits); if (!bump) {