Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Nightly publishing is split into two coordinated jobs so that npm credentials ne

- **`cd-github-releases.yml`** (GitHub Actions) runs nightly via cron (`0 8 * * *` UTC, ~12am PST) and on `workflow_dispatch`. It does **not** bump versions or push source changes to `main` β€” version bumps land on `main` through ordinary human-authored pull requests (for example, by running `npm run bump` locally and opening a PR). The cron is scheduled ~1 hour before the Azure CD pipeline (09:00 UTC) so any GitHub releases this job creates are picked up by that same night's publish run. The workflow has two jobs:
1. **`detect`** β€” checks out `main` with `fetch-depth: 0` and runs [`build/scripts/create-github-releases.mjs --check-only`](../../build/scripts/create-github-releases.mjs). The script walks the workspaces tree (no `npm ci` required), computes `${name}_v${version}` for every non-private workspace, and emits `hasMissingReleases=true` if any of those git tags do not yet exist.
2. **`release`** runs only when missing releases exist. Installs Node, the Rust toolchain (for `cargo package`), and the npm workspace dependencies, builds the repo, then runs the script in default mode. For every missing release the script: packs the npm tarball into `publish_artifacts_npm/`, packs the paired Rust crate (if `crates/<crate-name>/Cargo.toml` exists) into `publish_artifacts_crates/`, and creates the GitHub release with both assets attached via `gh release create --target <sha>`. The `gh` CLI creates the git tag atomically with the release, so "tag exists" and "release exists" are always the same fact β€” a failed release is safely retried on the next workflow run, with no orphan tag stranded behind. The script errors if a paired crate's version does not match the npm package's version β€” but this is purely a safety net: the [`postbump` hook in `beachball.config.js`](../../beachball.config.js) rewrites the crate's `Cargo.toml` (and the matching entry in `Cargo.lock`) automatically whenever `npm run bump` bumps the paired npm package, so the two stay in sync from the same commit.
2. **`release`** runs only when missing releases exist. Installs Node, the Rust toolchain (for `cargo package`), and the npm workspace dependencies, builds the repo, then runs the script in default mode. For every missing release the script: packs the npm tarball into `publish_artifacts_npm/`, packs any paired Rust crates into `publish_artifacts_crates/`, and creates the GitHub release with all assets attached via `gh release create --target <sha>`. `@microsoft/fast-build` is a bundled release: it uses one npm package, one tag, and one GitHub release containing both `microsoft-fast-build` and `microsoft-fast-convert` crate assets. The `gh` CLI creates the git tag atomically with the release, so "tag exists" and "release exists" are always the same fact β€” a failed release is safely retried on the next workflow run, with no orphan tag stranded behind. The script errors if a paired crate's version does not match the npm package's version β€” but this is purely a safety net: the [`postbump` hook in `beachball.config.js`](../../beachball.config.js) rewrites each crate's `Cargo.toml` (and the matching entry in `Cargo.lock`) automatically whenever `npm run bump` bumps the paired npm package, so they stay in sync from the same commit.
- **`azure-pipelines-cd.yml`** (Azure Pipelines) runs every night at **1am PST (`0 9 * * *` UTC)** with `always: true` so it still runs on no-op nights (it is checking external GitHub state, not repo commits). It is split into two stages so the heavy publish work is skipped on no-op nights:
1. **`Check`** β€” runs [`build/scripts/download-github-releases.mjs --check-only`](../../build/scripts/download-github-releases.mjs). The script walks the current publishable workspaces, keeps only workspaces whose current `${name}_v${version}` release tag exists, filters out tags that already have a `deployed/<tag>` counterpart, and emits Azure Pipelines output variables for the overall deployment decision, npm dist-tag, and each package-specific release tag. No network calls to GitHub, npm, or crates.io are needed.
2. **`Package`** β€” depends on `Check` and runs only when `needsDeployment == 'true'`. Conditional `DownloadGitHubRelease@0` tasks download undeployed release assets through the `fast` GitHub service connection, a shell step sorts them into `publish_artifacts_npm/` (`.tgz`) and `publish_artifacts_crates/` (`.crate`), configures npm to publish companion packages with the detected dist-tag, then `FAST.Release.PipelineTemplate.yml@fastPipelines` performs the actual `npm publish` / `cargo publish`. On success, the pipeline pushes a `deployed/<tag>` git marker tag for each release that was just published. The next nightly run will see those markers and skip the corresponding releases.
Expand All @@ -33,7 +33,7 @@ The `npm run checkchange` command runs `build/scripts/check-publish-pipeline.mjs
When adding a new non-private workspace that should publish through CD:

1. Ensure the workspace is included in the root `package.json` `workspaces` list and has a `name` and `version`.
2. If the package has a paired crate, place it at `crates/<crate-name>/Cargo.toml`, where `<crate-name>` is the npm package name with the leading `@` removed and `/` replaced by `-`. For example, `@microsoft/fast-build` pairs with `crates/microsoft-fast-build/Cargo.toml`.
2. If the package has paired crate assets, place each crate at `crates/<crate-name>/Cargo.toml`. By default, `<crate-name>` is the npm package name with the leading `@` removed and `/` replaced by `-`. `@microsoft/fast-build` is the special bundled release and pairs with both `crates/microsoft-fast-build/Cargo.toml` and `crates/microsoft-fast-convert/Cargo.toml`.
3. Add package-specific output variables to the `Package` stage in `azure-pipelines-cd.yml`. The output prefix is generated from the npm package name by converting `@microsoft/<name>` to camel case. For example, `@microsoft/fast-foo` emits `fastFooNeedsDeployment` and `fastFooReleaseTag`.
4. Add a conditional `DownloadGitHubRelease@0` task for the package using the `fast` GitHub service connection, `defaultVersionType: 'specificTag'`, and the package's `$(<prefix>ReleaseTag)` variable.
5. Confirm the artifact sorting step still covers the package assets. Packages should attach `.tgz` assets, and paired crates should also attach `.crate` assets.
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/ci-validate-rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,18 @@ on:
jobs:
test_rust:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
name: test_rust (${{ matrix.crate }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- crate: microsoft-fast-build
manifest-path: crates/microsoft-fast-build/Cargo.toml
cache-workspace: crates/microsoft-fast-build
- crate: microsoft-fast-convert
manifest-path: crates/microsoft-fast-convert/Cargo.toml
cache-workspace: crates/microsoft-fast-convert

steps:
- uses: actions/checkout@v4
Expand All @@ -34,7 +45,7 @@ jobs:
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: crates/microsoft-fast-build
workspaces: ${{ matrix.cache-workspace }}

- name: Run Rust tests
run: cargo test --manifest-path crates/microsoft-fast-build/Cargo.toml
run: cargo test --manifest-path ${{ matrix.manifest-path }}
57 changes: 33 additions & 24 deletions beachball.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ function npmNameToCrateName(npmName) {
return npmName.replace(/^@/, "").replace(/\//g, "-");
}

const bundledCratesByPackage = new Map([
["@microsoft/fast-build", ["microsoft-fast-build", "microsoft-fast-convert"]],
]);

function npmNameToCrateNames(npmName) {
return bundledCratesByPackage.get(npmName) ?? [npmNameToCrateName(npmName)];
}

/**
* Rewrite the `version = "..."` line for a specific `[package]` or
* `[[package]]` block (matched by the crate name) within Cargo TOML
Expand Down Expand Up @@ -66,41 +74,42 @@ function rewriteCargoVersion(content, crateName, newVersion, { manifest }) {
}

/**
* Beachball `postbump` hook: when an npm package with a paired Rust
* crate is bumped, rewrite the crate's `Cargo.toml` (and matching entry
* Beachball `postbump` hook: when an npm package with paired Rust
* crates is bumped, rewrite each crate's `Cargo.toml` (and matching entry
* in `Cargo.lock`, if present) so the crate version stays in lock-step
* with the npm version.
*
* Beachball commits any modified files in the same bump commit, so the
* Cargo updates land in the same PR as the package.json bump.
*/
function syncPairedCrateVersion(packagePath, name, version) {
const crateName = npmNameToCrateName(name);
const cargoTomlPath = join(__dirname, "crates", crateName, "Cargo.toml");
if (!existsSync(cargoTomlPath)) return;

const updatedToml = rewriteCargoVersion(
readFileSync(cargoTomlPath, "utf8"),
crateName,
version,
{ manifest: true },
);
if (updatedToml !== null) {
writeFileSync(cargoTomlPath, updatedToml);
console.log(`[beachball] Synced ${cargoTomlPath} to ${version}`);
}
for (const crateName of npmNameToCrateNames(name)) {
const cargoTomlPath = join(__dirname, "crates", crateName, "Cargo.toml");
if (!existsSync(cargoTomlPath)) continue;

const cargoLockPath = join(__dirname, "crates", crateName, "Cargo.lock");
if (existsSync(cargoLockPath)) {
const updatedLock = rewriteCargoVersion(
readFileSync(cargoLockPath, "utf8"),
const updatedToml = rewriteCargoVersion(
readFileSync(cargoTomlPath, "utf8"),
crateName,
version,
{ manifest: false },
{ manifest: true },
);
if (updatedLock !== null) {
writeFileSync(cargoLockPath, updatedLock);
console.log(`[beachball] Synced ${cargoLockPath} to ${version}`);
if (updatedToml !== null) {
writeFileSync(cargoTomlPath, updatedToml);
console.log(`[beachball] Synced ${cargoTomlPath} to ${version}`);
}

const cargoLockPath = join(__dirname, "crates", crateName, "Cargo.lock");
if (existsSync(cargoLockPath)) {
const updatedLock = rewriteCargoVersion(
readFileSync(cargoLockPath, "utf8"),
crateName,
version,
{ manifest: false },
);
if (updatedLock !== null) {
writeFileSync(cargoLockPath, updatedLock);
console.log(`[beachball] Synced ${cargoLockPath} to ${version}`);
}
}
}
}
Expand Down
77 changes: 50 additions & 27 deletions build/scripts/create-github-releases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@
* workspace's `package.json` (no `node_modules` required, so the
* `--check-only` mode can run before `npm ci`).
* 2. Skips workspaces whose package.json sets `private: true`.
* 3. For each remaining workspace, looks for a paired Rust crate at
* `crates/<crate-name>/Cargo.toml`, where the crate name is derived
* from the npm name by dropping the leading `@` and replacing `/`
* with `-` (so `@microsoft/fast-build` -> `microsoft-fast-build`).
* When a pair exists, the script errors if the two versions are not
* identical, forcing the version-bump PR to keep them in sync.
* 3. For each remaining workspace, looks for paired Rust crates at
* `crates/<crate-name>/Cargo.toml`. Most crate names are derived from
* the npm name by dropping the leading `@` and replacing `/` with `-`;
* `@microsoft/fast-build` bundles both `microsoft-fast-build` and
* `microsoft-fast-convert` into the same release. When a pair exists,
* the script errors if the two versions are not identical, forcing the
* version-bump PR to keep them in sync.
* 4. Computes `tag = ${name}_v${version}` (matching beachball's tag
* format) and skips the workspace if the git tag already exists
* (idempotent across re-runs).
* 5. Otherwise (when not `--check-only`) packs the npm tarball into
* `publish_artifacts_npm/` with `npm pack`, optionally packs the
* paired crate into `publish_artifacts_crates/` with
* `cargo package`, and creates the GitHub release with both
* paired crates into `publish_artifacts_crates/` with
* `cargo package`, and creates the GitHub release with all
* assets attached (`gh release create --target <sha>`). The `gh`
* CLI creates the git tag atomically with the release, so the
* tag and the release exist if and only if each other does.
Expand Down Expand Up @@ -76,6 +77,14 @@ function npmNameToCrateName(npmName) {
return npmName.replace(/^@/, "").replace(/\//g, "-");
}

const bundledCratesByPackage = new Map([
["@microsoft/fast-build", ["microsoft-fast-build", "microsoft-fast-convert"]],
]);

function npmNameToCrateNames(npmName) {
return bundledCratesByPackage.get(npmName) ?? [npmNameToCrateName(npmName)];
}

function shouldSkipCrates() {
return process.env.FAST_RELEASE_SKIP_CRATES === "true";
}
Expand All @@ -96,6 +105,29 @@ function readCargoTomlVersion(cargoTomlPath) {
return null;
}

function listPairedCrates(pkgName, pkgVersion) {
if (shouldSkipCrates()) return [];

const crates = [];
for (const crateName of npmNameToCrateNames(pkgName)) {
const cargoTomlPath = join("crates", crateName, "Cargo.toml");
if (!existsSync(cargoTomlPath)) continue;

const crateVersion = readCargoTomlVersion(cargoTomlPath);
if (crateVersion !== pkgVersion) {
throw new Error(
`Version mismatch for ${pkgName}: package.json is ${pkgVersion} ` +
`but ${cargoTomlPath} is ${crateVersion}. ` +
"Update one to match the other.",
);
}

crates.push({ crateName, cargoTomlPath });
}

return crates;
}

function listPublishableWorkspaces() {
const rootPkg = JSON.parse(readFileSync("package.json", "utf8"));
const patterns = rootPkg.workspaces || [];
Expand Down Expand Up @@ -123,25 +155,13 @@ function listPublishableWorkspaces() {
if (pkg.private === true) continue;
if (!pkg.name || !pkg.version) continue;

const crateName = npmNameToCrateName(pkg.name);
const cargoTomlPath = join("crates", crateName, "Cargo.toml");
const hasCrate = existsSync(cargoTomlPath) && !shouldSkipCrates();

if (hasCrate) {
const crateVersion = readCargoTomlVersion(cargoTomlPath);
if (crateVersion !== pkg.version) {
throw new Error(
`Version mismatch for ${pkg.name}: package.json is ${pkg.version} but ${cargoTomlPath} is ${crateVersion}. Update one to match the other.`,
);
}
}
const crates = listPairedCrates(pkg.name, pkg.version);

workspaces.push({
location,
name: pkg.name,
version: pkg.version,
crateName: hasCrate ? crateName : null,
cargoTomlPath: hasCrate ? cargoTomlPath : null,
crates,
});
}

Expand Down Expand Up @@ -174,8 +194,11 @@ console.log(`Missing git tag / release: ${missing.length}`);

if (missing.length > 0) {
console.log("\nPackages that need a release:");
for (const { name, version, crateName } of missing) {
const suffix = crateName ? ` (+ crate ${crateName})` : "";
for (const { name, version, crates } of missing) {
const suffix =
crates.length > 0
? ` (+ crates ${crates.map(crate => crate.crateName).join(", ")})`
: "";
console.log(` - ${name}@${version}${suffix}`);
}
}
Expand All @@ -201,7 +224,7 @@ mkdirSync(CRATES_DIR, { recursive: true });
let created = 0;
let hasErrors = false;

for (const { name, version, location, crateName, cargoTomlPath } of missing) {
for (const { name, version, location, crates } of missing) {
const tag = `${name}_v${version}`;
const assets = [];

Expand All @@ -216,7 +239,7 @@ for (const { name, version, location, crateName, cargoTomlPath } of missing) {
]);
assets.push(join(NPM_DIR, JSON.parse(packJson)[0].filename));

if (cargoTomlPath) {
for (const { crateName, cargoTomlPath } of crates) {
console.log(`Packaging crate ${crateName}@${version}...`);
run(
"cargo",
Expand Down Expand Up @@ -251,7 +274,7 @@ for (const { name, version, location, crateName, cargoTomlPath } of missing) {
"",
"Version bumps were landed via a regular pull request. The attached",
"assets will be downloaded and published to npm" +
(cargoTomlPath ? " and crates.io" : "") +
(crates.length > 0 ? " and crates.io" : "") +
" by the nightly Azure release pipeline.",
].join("\n");

Expand Down
44 changes: 32 additions & 12 deletions build/scripts/download-github-releases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
* versions plus matching release tags. This keeps historical bare beachball tags
* from being treated as deployable releases while still working on a
* freshly-cloned 1ES agent with no `node_modules` or cargo registry.
*
* Most workspaces map to at most one crate by name convention.
* `@microsoft/fast-build` intentionally maps to both `microsoft-fast-build`
* and `microsoft-fast-convert`, but still uses one npm package release tag
* and one Azure download task.
*/

import { execFileSync } from "node:child_process";
Expand Down Expand Up @@ -62,6 +67,14 @@ function npmNameToCrateName(npmName) {
return npmName.replace(/^@/, "").replace(/\//g, "-");
}

const bundledCratesByPackage = new Map([
["@microsoft/fast-build", ["microsoft-fast-build", "microsoft-fast-convert"]],
]);

function npmNameToCrateNames(npmName) {
return bundledCratesByPackage.get(npmName) ?? [npmNameToCrateName(npmName)];
}

function shouldSkipCrates() {
return process.env.FAST_RELEASE_SKIP_CRATES === "true";
}
Expand All @@ -88,6 +101,24 @@ function readCargoTomlVersion(cargoTomlPath) {
return null;
}

function validatePairedCrates(pkgName, pkgVersion) {
if (shouldSkipCrates()) return;

for (const crateName of npmNameToCrateNames(pkgName)) {
const cargoTomlPath = join("crates", crateName, "Cargo.toml");
if (!existsSync(cargoTomlPath)) continue;

const crateVersion = readCargoTomlVersion(cargoTomlPath);
if (crateVersion !== pkgVersion) {
throw new Error(
`Version mismatch for ${pkgName}: package.json is ${pkgVersion} ` +
`but ${cargoTomlPath} is ${crateVersion}. ` +
"Update one to match the other.",
);
}
}
}

function listPublishableWorkspaces() {
const rootPkg = JSON.parse(readFileSync("package.json", "utf8"));
const patterns = rootPkg.workspaces || [];
Expand Down Expand Up @@ -115,18 +146,7 @@ function listPublishableWorkspaces() {
if (pkg.private === true) continue;
if (!pkg.name || !pkg.version) continue;

const crateName = npmNameToCrateName(pkg.name);
const cargoTomlPath = join("crates", crateName, "Cargo.toml");
const hasCrate = existsSync(cargoTomlPath) && !shouldSkipCrates();

if (hasCrate) {
const crateVersion = readCargoTomlVersion(cargoTomlPath);
if (crateVersion !== pkg.version) {
throw new Error(
`Version mismatch for ${pkg.name}: package.json is ${pkg.version} but ${cargoTomlPath} is ${crateVersion}. Update one to match the other.`,
);
}
}
validatePairedCrates(pkg.name, pkg.version);

workspaces.push({
location,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add FAST declarative template conversion to the fast CLI.",
"packageName": "@microsoft/fast-build",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "Update WebUI integration fixtures to generate templates with fast convert.",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
Loading
Loading