-
Notifications
You must be signed in to change notification settings - Fork 618
chore: automate Rust crate versioning in publish-ci #7425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| # Design β @microsoft/fast-build-tools | ||
|
|
||
| This document explains how the build utilities work internally. | ||
|
|
||
| --- | ||
|
|
||
| ## publish-rust.mjs | ||
|
|
||
| ### Purpose | ||
|
|
||
| Bridges the gap between Beachball (which manages npm package versioning) and the Rust crate (`microsoft-fast-build`) which lives outside the npm workspace structure. The script replicates Beachball's "inspect commits β bump version β package artifact" pattern for the Rust side. | ||
|
|
||
| ### Data flow | ||
|
|
||
| ``` | ||
| git tag --list "microsoft-fast-build_v*" | ||
| β | ||
| βΌ | ||
| latestTag (or null if no tags exist) | ||
| β | ||
| βΌ | ||
| git log ${latestTag}..HEAD -- crates/microsoft-fast-build/ | ||
| β | ||
| βΌ | ||
| commit messages (subject + body) | ||
| β | ||
| βΌ | ||
| determineBump(commits) | ||
| β | ||
| ββ BREAKING CHANGE / type!: β "major" | ||
| ββ feat: β "minor" | ||
| ββ anything else β "patch" | ||
| β | ||
| βΌ | ||
| bumpVersion(current, bump) | ||
| β | ||
| βΌ | ||
| updateCargoToml(newVersion) β in-place version replacement | ||
| β | ||
| βΌ | ||
| cargo package --no-verify --allow-dirty | ||
| β | ||
| βΌ | ||
| copy .crate β publish_artifacts/cargo/ | ||
| ``` | ||
|
|
||
| ### Design decisions | ||
|
|
||
| 1. **Tag-based range** β The script finds the latest `microsoft-fast-build_v*` tag via `git tag --list --sort=-version:refname` rather than constructing a tag name from the current `Cargo.toml` version. This avoids breakage if the Cargo.toml version diverges from the most recent tag. | ||
|
|
||
| 2. **Output to `publish_artifacts/cargo/`** β Packaged crate files are placed under the existing `publish_artifacts/` directory (already in `.gitignore`) in a `cargo/` subdirectory. This mirrors the npm pattern (`publish_artifacts/` for npm packages) without requiring additional `.gitignore` entries. | ||
|
|
||
| 3. **Conventional commit parsing** β The regex patterns match standard conventional commit prefixes. The `BREAKING CHANGE` trailer and `type!:` syntax both trigger major bumps, matching the conventional commits specification. | ||
|
|
||
| 4. **No-op on no changes** β If no commits touch the crate directory since the last tag, the script exits with code 0 and produces no side effects. This allows `publish-ci` to always invoke it safely. | ||
|
|
||
| 5. **`--no-verify --allow-dirty`** β `cargo package` is invoked with these flags because the working tree may contain uncommitted Beachball changes and the crate tests are validated separately in CI. | ||
|
|
||
| ### Integration | ||
|
|
||
| The script is chained before `beachball publish` in the root `package.json`: | ||
|
|
||
| ```json | ||
| "publish-ci": "node build/publish-rust.mjs && beachball publish -y --no-publish" | ||
| ``` | ||
|
|
||
| Running the Rust step first ensures the `Cargo.toml` version bump is captured before Beachball creates its commit and tags. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # @microsoft/fast-build-tools | ||
|
|
||
| Private build utilities for the FAST monorepo. These scripts are not published β they support internal CI, formatting, and packaging workflows. | ||
|
|
||
| ## Scripts | ||
|
|
||
| ### `publish-rust.mjs` | ||
|
|
||
| Automates version bumping and packaging of the `microsoft-fast-build` Rust crate during CI publish runs. | ||
|
|
||
| **How it works:** | ||
|
|
||
| 1. Finds the latest Beachball-generated tag matching `microsoft-fast-build_v*` | ||
| 2. Inspects git commits touching `crates/microsoft-fast-build/` since that tag | ||
| 3. Determines the version bump type using [conventional commits](https://www.conventionalcommits.org/): | ||
| - `BREAKING CHANGE` / `feat!:` / `fix!:` / `refactor!:` / `chore!:` β **major** | ||
| - `feat:` β **minor** | ||
| - anything else β **patch** | ||
| 4. Updates the version in `crates/microsoft-fast-build/Cargo.toml` | ||
| 5. Runs `cargo package` and copies the `.crate` file to `publish_artifacts/cargo/` | ||
| 6. Exits cleanly (no-op) if no relevant commits exist since the last release | ||
|
|
||
| **Usage:** | ||
|
|
||
| The script is invoked automatically by the root `publish-ci` npm script: | ||
|
|
||
| ```bash | ||
| npm run publish-ci | ||
| # runs: node build/publish-rust.mjs && beachball publish -y --no-publish | ||
| ``` | ||
|
|
||
| ### `biome-changed.mjs` | ||
|
|
||
| Runs Biome linting/formatting only on files with uncommitted git changes. Used by `npm run lint`, `npm run biome:check`, and related commands. | ||
|
|
||
| ### `clean.mjs` | ||
|
|
||
| Deletes specified directories. Used by workspace `clean` scripts to remove `dist/` and other build output. | ||
|
|
||
| ### `get-package-json.js` | ||
|
|
||
| Resolves the directory of a dependency's `package.json`. Used internally by build configuration. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,126 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #!/usr/bin/env node | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Bumps the Rust crate version based on conventional commits since the last | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 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!: / 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { execSync } from "node:child_process"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { copyFileSync, globSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { dirname, join } 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 getCommitsSinceLastTag(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 = latestTag ? `${latestTag}..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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /^(feat|fix|refactor|chore)!(\([^)]*\))?:/m.test(commits) | |
| /^(feat|fix|refactor|chore)(\([^)]*\))?!:/m.test(commits) |
Copilot
AI
Apr 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This replacement updates the first line starting with version = ... anywhere in the file, which can be incorrect if a dependency table (or another section) appears before [package] and contains version = "...". Consider either parsing TOML and updating [package].version, or restricting the edit to the [package] section (e.g., find the [package] header and only replace version until the next header).
Copilot
AI
Apr 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If cargo is unavailable/misconfigured in the environment, execSync will throw with a generic stack trace thatβs hard to diagnose from CI logs. Catch the failure and rethrow a targeted error that mentions the exact command and common remediation (e.g., ensuring the Rust toolchain is installed and cargo is on PATH), so publish failures are actionable.
| execSync( | |
| `cargo package --manifest-path "${cargoTomlPath}" --no-verify --allow-dirty`, | |
| { stdio: "inherit" }, | |
| ); | |
| const cargoPackageCommand = `cargo package --manifest-path "${cargoTomlPath}" --no-verify --allow-dirty`; | |
| try { | |
| execSync(cargoPackageCommand, { stdio: "inherit" }); | |
| } catch (error) { | |
| throw new Error( | |
| `Failed to run '${cargoPackageCommand}'. Ensure the Rust toolchain is installed and 'cargo' is available on PATH before publishing.`, | |
| { cause: error }, | |
| ); | |
| } |
Copilot
AI
Apr 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cargo package may place artifacts under the workspace/global target directory (commonly ${repoRoot}/target/package) rather than ${crateDir}/target/package when invoked within a Cargo workspace, which would make the subsequent glob fail even though packaging succeeded. A more robust approach is to resolve Cargoβs target_directory via cargo metadata (or pass an explicit --target-dir) and use that location for target/package.
| 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"); | |
| function getCargoTargetDirectory() { | |
| const metadataJson = execSync( | |
| `cargo metadata --manifest-path "${cargoTomlPath}" --format-version 1 --no-deps`, | |
| { encoding: "utf-8" }, | |
| ); | |
| const metadata = JSON.parse(metadataJson); | |
| if (!metadata.target_directory) { | |
| throw new Error("Could not determine Cargo target_directory from cargo metadata"); | |
| } | |
| return metadata.target_directory; | |
| } | |
| function packageCrate(version) { | |
| mkdirSync(outputDir, { recursive: true }); | |
| execSync( | |
| `cargo package --manifest-path "${cargoTomlPath}" --no-verify --allow-dirty`, | |
| { stdio: "inherit" }, | |
| ); | |
| const targetPackageDir = join(getCargoTargetDirectory(), "package"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The log format concatenates commits without a guaranteed separator between entries (especially when
%bis empty), which can make the bump-detection regexes brittle (anchors with^/mdepend on reliable newlines). Add an explicit delimiter/newline between commits in the pretty format (or use a NUL-delimited format) so each commit boundary is unambiguous during parsing.