Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions build/DESIGN.md
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.
42 changes: 42 additions & 0 deletions build/README.md
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.
126 changes: 126 additions & 0 deletions build/publish-rust.mjs
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/`,
Copy link

Copilot AI Apr 14, 2026

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 %b is empty), which can make the bump-detection regexes brittle (anchors with ^/m depend 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.

Suggested change
`git -C "${repoRoot}" log ${range} --pretty=format:"%s%n%b" -- crates/microsoft-fast-build/`,
`git -C "${repoRoot}" log ${range} --pretty=format:"%s%n%b%n%n" -- crates/microsoft-fast-build/`,

Copilot uses AI. Check for mistakes.
{ 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)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type! regex does not match the conventional-commits breaking syntax type(scope)!: (the ! comes after the optional scope). As written, it matches feat!(scope): but will miss common messages like feat(parser)!: ..., causing a major bump to be incorrectly downgraded. Update the pattern to match type(scope)!: (and optionally type!:) correctly.

Suggested change
/^(feat|fix|refactor|chore)!(\([^)]*\))?:/m.test(commits)
/^(feat|fix|refactor|chore)(\([^)]*\))?!:/m.test(commits)

Copilot uses AI. Check for mistakes.
) {
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);
}
Comment on lines +87 to +91
Copy link

Copilot AI Apr 14, 2026

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 uses AI. Check for mistakes.

function packageCrate(version) {
mkdirSync(outputDir, { recursive: true });
execSync(
`cargo package --manifest-path "${cargoTomlPath}" --no-verify --allow-dirty`,
{ stdio: "inherit" },
);
Comment on lines +95 to +98
Copy link

Copilot AI Apr 14, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
const targetPackageDir = join(crateDir, "target", "package");
Comment on lines +93 to +99
Copy link

Copilot AI Apr 14, 2026

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.

Suggested change
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");

Copilot uses AI. Check for mistakes.
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}/`);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading