diff --git a/build/DESIGN.md b/build/DESIGN.md new file mode 100644 index 00000000000..850273b26c3 --- /dev/null +++ b/build/DESIGN.md @@ -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. diff --git a/build/README.md b/build/README.md new file mode 100644 index 00000000000..0d6ccffb61c --- /dev/null +++ b/build/README.md @@ -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. diff --git a/build/publish-rust.mjs b/build/publish-rust.mjs new file mode 100644 index 00000000000..c3a92ee0c2e --- /dev/null +++ b/build/publish-rust.mjs @@ -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) + ) { + 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 = getCommitsSinceLastTag("microsoft-fast-build"); +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 ded0479976b..41ad84eba30 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": "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",