ci: split CD into GitHub release + Azure publish stages, with crate support#7531
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the FAST monorepo’s nightly continuous deployment flow to split “release artifact creation” (GitHub Actions) from “publishing to registries” (Azure Pipelines), keeping npm credentials confined to Azure while using git tags + GitHub Releases as the idempotency/source-of-truth mechanism. It also adds paired Rust crate support (for @microsoft/fast-build ↔ microsoft-fast-build) and a Beachball postbump hook to keep crate versions in sync with npm versions.
Changes:
- Add a new GitHub Actions workflow (
cd-github-releases.yml) + script to create per-package GitHub releases with packed.tgz(and optional.crate) assets. - Update Azure CD pipeline to detect undeployed releases, download assets from GitHub Releases, publish via the existing template, and push
deployed/<tag>marker tags. - Add a Beachball
postbumphook to automatically sync a paired crate’sCargo.toml/Cargo.lockversion with its npm package version.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Removes now-obsolete publish-ci script (Azure no longer uses Beachball for packing). |
| crates/microsoft-fast-build/Cargo.toml | Bumps crate version to align with the existing npm package version. |
| crates/microsoft-fast-build/Cargo.lock | Updates the lockfile entry to match the crate’s new version. |
| CONTRIBUTING.md | Documents the maintainer bump-and-release workflow for the new CD design. |
| build/scripts/create-github-releases.mjs | New script to detect missing ${name}_v${version} tags and create GitHub releases with .tgz/.crate assets. |
| build/scripts/download-github-releases.mjs | New script to detect undeployed tags and download/sort GitHub release assets for Azure publishing. |
| beachball.config.js | Adds a postbump hook to sync paired Rust crate versions during beachball bump. |
| azure-pipelines-cd.yml | Splits CD into “Check” + “Package” stages; downloads GitHub release assets and tags successful deployments. |
| .gitignore | Updates ignored artifact directories to match the new split npm/crate/meta/stage folders. |
| .github/workflows/README.md | Documents the new two-part CD flow (GitHub release creation + Azure publish). |
| .github/workflows/cd-github-releases.yml | New workflow that creates GitHub releases on push-to-main / manual dispatch. |
|
Addressed all four review comments in bfbf8f6:
|
|
Addressed the six new review comments in aa73f47:
Smoke-tested locally against
|
Split the single nightly Azure CD job into two coordinated nightly jobs so
npm credentials never leave the Azure environment:
- .github/workflows/cd-github-releases.yml (new) runs at midnight PST.
Bumps versions, packs tarballs, creates a GitHub release per package
(tag format `${name}_v${version}`, matching beachball), and uploads
each tarball as a release asset.
- azure-pipelines-cd.yml (modified) now runs at 1am PST. It downloads
any GitHub release tarball that has not yet been published to npm into
publish_artifacts/, then hands off to the existing
FAST.Release.PipelineTemplate template for the actual npm publish.
Adds build/scripts/release-utils.mjs (shared helpers),
build/scripts/create-github-releases.mjs, and
build/scripts/download-github-releases.mjs. No new npm dependencies.
Updates .github/workflows/README.md to document the split.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per review feedback, the 600+ lines of custom Node.js GitHub REST client (release-utils.mjs + two .mjs scripts) are overkill when the gh CLI is pre-installed on every GHA runner and standard on Azure pool images. Beachball already handles the bump / pack / commit / git-tag / push side of the release flow; it just does not natively manage GitHub releases or download-missing-from-npm flows. That bridging code is trivial when leaning on existing tools: - build/scripts/create-github-releases.sh: tar + jq read the embedded package.json from each tarball, then gh release create uploads it. - build/scripts/download-github-releases.sh: gh release list iterates beachball-style tags, npm view skips already-published versions, and gh release download fetches the missing .tgz assets. Updates both workflow YAMLs to call the .sh scripts and the workflows README to point at them. Removes the .mjs scripts and release-utils.mjs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replaces the bash helpers introduced in the previous commit with equivalent Node.js (.mjs) scripts. The scripts remain thin wrappers around the gh, npm, and tar CLIs — no extra npm dependencies or custom GitHub API client. JavaScript matches the conventions of the existing build/*.mjs helpers in the repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The nightly GitHub workflow no longer runs `npm run publish-ci`, so it
no longer bumps versions, creates git tags, commits, or pushes to main.
Version bumps now land on main through ordinary human-authored pull
requests (e.g. running `npm run bump` locally and opening a PR).
After such a PR is merged, the workflow re-packs every non-private
workspace at its current package.json version and creates a GitHub
release for any `${name}_v${version}` tag that does not already exist.
Re-runs are idempotent: existing releases are skipped.
This also removes the git user config, the elevated checkout token, and
the deep `fetch-depth: 0` from the workflow since the action no longer
writes to the repository.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Detect stage lists GitHub releases and, for each beachball-style
tag, checks whether that exact `${name}@${version}` is already on
npm. It emits a `hasReleases` output variable based on whether any
tarballs remain to be published.
The Publish stage depends on Detect and runs only when
`hasReleases == 'true'`. This means `npm ci`, the duplicate download,
and the FAST.Release.PipelineTemplate are skipped entirely on no-op
nights, which is now the common case once we are caught up.
The download is re-run inside Publish to bring the tarballs onto its
agent (stages run on separate VMs). The script is idempotent and the
tarballs are small, so the duplicate download is cheap compared to
shuttling them between agents as pipeline artifacts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirror the patterns used by microsoft/webui's CD pipeline: - GitHub workflow now triggers on push to main (plus workflow_dispatch), using `git rev-parse refs/tags/<tag>` for idempotency instead of a GitHub API call. Requires `fetch-depth: 0` to see all tags. - Workflow now packs paired Rust crates (where `crates/<crate-name>/Cargo.toml` exists, derived by dropping `@` and replacing `/` with `-` in the npm name) into a separate `publish_artifacts_crates/` folder, alongside npm tarballs in `publish_artifacts_npm/`. Errors out if the crate version does not match the npm version. - Bumps `microsoft-fast-build` crate to 0.7.0 to match the paired `@microsoft/fast-build` npm package. - Azure pipeline now uses `deployed/<tag>` git marker tags (pushed after a successful publish) instead of `npm view`/`cargo search` to decide what to publish — avoids unreliable npm.org / crates.io calls from 1ES agents. Cron uses `always: true` so it still runs on no-op nights, since the work depends on external GitHub state rather than repo commits. - Both stages of the Azure pipeline run with separate npm/crates publish folders so the existing FAST.Release.PipelineTemplate can be pointed at them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire a `postbump` hook into `beachball.config.js` so that whenever `npm run bump` bumps an npm package that has a paired Rust crate (e.g. `@microsoft/fast-build` -> `crates/microsoft-fast-build/`), the crate's `Cargo.toml` and `Cargo.lock` entries get rewritten to the new version in the same bump commit. The crate name is derived from the npm name by dropping the leading `@` and replacing `/` with `-`. The hook is a no-op for npm packages without a paired crate. This removes the manual step that authors of a version-bump PR for `@microsoft/fast-build` would otherwise have to remember. The version-mismatch check in `create-github-releases.mjs` remains as a safety net in case someone hand-edits one file without the other. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ifact folders Use `publish_artifacts_npm/` for beachball's local pack output (the only artifacts beachball produces are npm tarballs) and update .gitignore to cover the four publish folders the new CD scripts can write to (npm, crates, meta, stage). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add `build/scripts/pack-crates.mjs` and chain it after beachball in the `publish-ci` npm script so a single `npm run publish-ci` produces both the npm tarballs (under `publish_artifacts_npm/`, via beachball's `packToPath`) and the paired Rust `.crate` archives (under `publish_artifacts_crates/`, via `cargo package`). Also drop a handful of `.tgz` files in `publish_artifacts/` that were accidentally committed when the old gitignore entry was renamed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Neither CD pipeline runs `npm run publish-ci` — the GitHub workflow calls `npm pack` / `cargo package` directly from `create-github-releases.mjs`. Drop: - `build/scripts/pack-crates.mjs` (only consumer was publish-ci) - `"publish-ci"` script in package.json - `packToPath` in beachball.config.js (only read when packing via beachball, which nothing in the new flow does) The four `publish_artifacts_*/` folders are still produced and consumed at runtime by the CD scripts, so the .gitignore entries stay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… job The release script previously created an annotated git tag and pushed it before calling `gh release create`. If `gh release create` failed (transient network blip, asset upload error, rate limit) after the tag was already pushed, the tag would exist on the remote but no release would exist for it. On the next workflow run, `git rev-parse refs/tags/$tag` would return success and the script would skip the release forever \u2014 stranded forever. Let `gh release create` create the tag itself (via `--target <sha>`), which is atomic with the release creation. Now "tag exists" and "release exists" are always the same fact, and a failed release is safely retried on the next workflow run. The first manual publish run (zero existing tags, five packages) benefits the most: any single transient failure no longer strands that package's tag. As a follow-on, drop the no-longer-needed git user config and the `token:` argument to `actions/checkout`. `gh release create` uses the `GH_TOKEN` env var, not the checkout token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the maintainer workflow for cutting a release: 1. Create a bump branch 2. Run `npm run bump` (which consumes change files, bumps package.json versions, runs the postbump hook to sync paired Cargo.toml + Cargo.lock, and deletes change files) 3. Review the diff and run `create-github-releases.mjs --check-only` to preview what CD will publish 4. Open and merge the bump PR 5. After merge, cd-github-releases.yml creates GitHub releases and the nightly Azure pipeline publishes to npm + crates.io Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- download-github-releases.mjs: tighten release tag regex to `_v\d+\.\d+\.\d+` (require a patch component) and explicitly exclude `deployed/<tag>` marker tags before applying it, so markers aren't re-treated as undeployed releases. - download-github-releases.mjs: clear `publish_artifacts_stage/` before each iteration and delete unknown-type files after warning, so leftovers from a previous tag don't trigger repeated warnings or incorrect moves on subsequent tags. - azure-pipelines-cd.yml: make the Mark-deployed step idempotent by checking for the marker tag via `git rev-parse --verify` before creating + pushing. Previously, a re-run after partial success would have aborted with `set -e` on the first pre-existing tag. - .github/workflows/README.md: update the release-job description to reflect the atomic `gh release create --target <sha>` flow (the earlier docs still claimed the workflow pushed an annotated tag before creating the release). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- download-github-releases.mjs: fail fast with a clear message when `GH_TOKEN` is missing in non-`--check-only` mode (where `gh release download` would otherwise produce a generic auth error mid-loop). - download-github-releases.mjs: clear `publish_artifacts_npm/`, `publish_artifacts_crates/`, and `publish_artifacts_meta/` at the start of non-check mode (in addition to the per-iteration `publish_artifacts_stage/` cleanup) so stale `.tgz`/`.crate` files from a partially-failed previous run cannot be reused. - download-github-releases.mjs: refuse to mark a tag as processed when its release contained no `.tgz` or `.crate` assets. Without this the Azure pipeline would push the `deployed/<tag>` marker for a release that never actually published anything, so the problem would never be retried. - download-github-releases.mjs + create-github-releases.mjs: narrow the `catch (error)` payload with `error instanceof Error ? error.message : String(error)` so we do not log `undefined` (or crash trying to read `.message`) if a non-`Error` value is thrown. - create-github-releases.mjs: fail fast with a clear message when `GH_TOKEN` is missing in non-`--check-only` mode (the script needs it to call `gh release create`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
aa73f47 to
3ad3ca6
Compare
- Replace the DST-fragile `1am PST = 09:00 UTC` cron comment with a UTC-anchored phrasing that also acknowledges the \~1am PT / \~2am PDT seasonal drift, so readers do not assume the schedule tracks Pacific time year-round. - Drop the unused `undeployedTags` stage variable from the Package stage. It was bound to the Check stage output but never consumed downstream — the Package stage's own `download-github-releases.mjs` invocation re-enumerates undeployed releases from the local git tag set, so removing the captured variable also removes the implicit expectation that the two stages share that list. The script still emits the output via `##vso[task.setvariable]` for visibility in the Check stage log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the two new review comments in 1261d1c:
|
Previously the Azure pipeline created the marker with a bare
`git tag "${DEPLOY_TAG}"`, which anchors the marker to the agent's
current HEAD. That is usually correct on the first run (since the
release tag was created on the same main HEAD that the pipeline
checks out shortly after), but it is fragile on re-runs: if the
pipeline is replayed weeks later from a newer commit, the marker
would silently point at an unrelated commit, breaking the audit
trail that links a publication back to the exact source the
tarball was built from.
Use `git tag "${DEPLOY_TAG}" "refs/tags/${tag}"` so the marker
always anchors to the same commit as its release tag, regardless
of when the mark-deployed step runs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the new review comment in 51302d4:
Verified the rerun-safety property in a local sandbox: with the release tag at commit A and HEAD at a later commit B, the new code anchors |
The reviewer rightly pointed out the prior phrasing ("does not yet
exist on `origin`") implied the script queries the remote, while
`create-github-releases.mjs` actually just runs
`git rev-parse --verify refs/tags/<tag>` against the local refs.
Reword the preview command's description to say "is not present in
the local git tag list" and add a note suggesting
`git fetch --tags --prune origin` for contributors who want the
preview to reflect remote state.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed in a41e940e1:
|
…tion - download-github-releases.mjs: tighten RELEASE_TAG_RE to `/_v\d+\.\d+\.\d+(?:-[\w.-]+)?$/` so it matches the complete beachball release tag format (including optional dot-separated prerelease) anchored at end-of-string. The prior unanchored regex would treat any tag containing `_v<semver>` somewhere in the middle as a release tag, which could have caused the Azure CD pipeline to attempt a `gh release download` for an unintended tag. Also patched the PR body (no commit) to drop the stale "pushes an annotated tag" and `gh release create --verify-tag` claims, replacing them with the actual atomic `gh release create --target <sha>` flow the script implements, and rewrote the DST-fragile cron description to be UTC-anchored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed both new review comments in e95865bc4:
|
Drop the `push: branches: [main]` trigger from `.github/workflows/cd-github-releases.yml`. The workflow now runs only on: - A nightly cron at `0 8 * * *` UTC (~12am Pacific in standard time, drifting an hour during US daylight time), and - `workflow_dispatch` for manual runs. This puts the release-creation cron ~1 hour ahead of the existing Azure CD pipeline (`0 9 * * *` UTC), so any GitHub release this workflow creates is ready in time for that same night's `npm publish` / `cargo publish` cycle. Removing the push trigger means the workflow no longer runs on every `main` commit (so non-bump merges no longer kick off a no-op release detect job). Updated the surrounding docs to match: - `.github/workflows/README.md` — the GitHub Actions trigger line is now "nightly cron + workflow_dispatch" instead of "push to main + workflow_dispatch". - `CONTRIBUTING.md` Publishing > After merge — explains that the release is created by the next nightly cron and offers `gh workflow run cd-github-releases.yml` as the manual alternative for maintainers who don't want to wait. The Azure pipeline is intentionally unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pull Request
📖 Description
Split the single nightly Azure CD job into two coordinated jobs so that npm credentials never leave the Azure environment. GitHub Actions packages each non-private workspace into an auditable GitHub release; the existing Azure release template publishes those tarballs to npm and crates.io. GitHub Releases are the source of truth, and
deployed/<tag>git marker tags track which releases have already been published.GitHub Actions side (
cd-github-releases.yml)0 8 * * *UTC, ~12am PST) plusworkflow_dispatch. Does not bump versions or push source changes — version bumps land via ordinary human-authored PRs (e.g., fromnpm run bump). The cron is scheduled ~1 hour before the Azure CD pipeline (09:00 UTC) so any releases this job creates are picked up by that same night's publish run.detectrunscreate-github-releases.mjs --check-only. The script walks the workspaces tree (nonpm cirequired), computes${name}_v${version}for each non-private workspace, and emitshasMissingReleases=trueif any of those git tags do not yet exist (usesgit rev-parse refs/tags/<tag>— no API call).releaseruns only when missing releases exist. Installs Node, the Rust toolchain (forcargo package), builds, then for every missing release: packs the npm tarball intopublish_artifacts_npm/, packs the paired Rust crate (wherecrates/<crate-name>/Cargo.tomlexists — derived by dropping@and replacing/with-in the npm name) intopublish_artifacts_crates/, and creates the GitHub release with both assets attached viagh release create --target <sha>. TheghCLI creates a lightweight${name}_v${version}tag atomically with the release, so the release and its tag exist iff each other does — eliminating the orphan-tag risk that an explicit pre-ghtag-push would have.Azure Pipelines side (
azure-pipelines-cd.yml)0 9 * * *(09:00 UTC daily; ~1am Pacific in standard time, ~2am during DST) withalways: trueso it still runs on no-op nights — work depends on external GitHub state, not repo commits.Checkrunsdownload-github-releases.mjs --check-only. The script lists every git tag matching the beachball-style${name}_v${version}pattern that does not also have adeployed/<tag>counterpart and emits the list via Azure Pipelinessetvariableoutput variables (needsDeployment,undeployedTags). No network calls to npm.org or crates.io.Packagedepends onCheckand runs only whenneedsDeployment == 'true'. Downloads every undeployed release's assets viagh release download, sorts them intopublish_artifacts_npm/(.tgz) andpublish_artifacts_crates/(.crate), hands off toFAST.Release.PipelineTemplate.yml@fastPipelines, and on success pushes adeployed/<tag>marker tag for each release that was just published. The next nightly run sees those markers and skips the corresponding releases.Paired npm package / Rust crate versioning
@microsoft/fast-buildis paired with themicrosoft-fast-buildRust crate atcrates/microsoft-fast-build/. This PR bumps that crate from 0.1.0 → 0.7.0 to match the existing npm version.postbumphook inbeachball.config.jsautomatically rewrites the crate'sCargo.tomlandCargo.lockwhenevernpm run bumpbumps the paired npm package, so PR authors don't have to remember to sync them by hand. The crate name is derived from the npm name by dropping@and replacing/with-; the hook is a no-op for npm packages without a paired crate.create-github-releases.mjsstill errors out at release time if the two versions disagree, as a safety net.Other changes
build/scripts/create-github-releases.mjsandbuild/scripts/download-github-releases.mjs— thin Node.js wrappers aroundgh,npm,cargo, andgit. No new npm dependencies and no custom GitHub API client..github/workflows/README.mdto document the new two-stage CD flow.👩💻 Reviewer Notes
${name}_v${version}on the GitHub side,deployed/${name}_v${version}on the Azure side. Neither side needs to talk to npm.org or crates.io to decide whether work is required.${name}_v${version}(seenode_modules/beachball/lib/git/generateTag.js), so the git tag, the GitHub release tag, and the eventual npm publish all share a single identifier.secrets.GH_TOKEN(same secret already used bycd-gh-pages.yml). No new secrets are required on either side.FAST.Release.PipelineTemplate.yml@fastPipelines: the existing template may expect a singlepublish_artifacts/folder rather than the separatepublish_artifacts_npm/+publish_artifacts_crates/layout. If that's the case, either the template or this PR needs a follow-up tweak to bridge the layout difference.📑 Test Plan
This change affects scheduled CD only, so it cannot be exercised end-to-end from a PR build. Verified locally:
npm run format:checkandnpm run checkchangepass.node --checkpasses on both new scripts andbeachball.config.js;npx @biomejs/biome checkpasses too.js-yamlparses both.github/workflows/cd-github-releases.ymlandazure-pipelines-cd.ymlwithout errors.create-github-releases.mjs --check-onlyagainst this repo correctly reports the 5 publishable workspaces (one with a paired crate) andhasMissingReleases=true.download-github-releases.mjs --check-onlyagainst the livemicrosoft/fastrepo correctly returns 0 undeployed tags. (After cleaning up some test releases that were created during development, the five publishable workspaces' tags now exist onoriginand each one has adeployed/<tag>marker, so the script correctly reports nothing to publish.)beachball bumptest: created apatchchange file for@microsoft/fast-build, rannpx beachball bump, confirmed thepostbumphook synced bothcrates/microsoft-fast-build/Cargo.tomland the matchingCargo.lockentry from0.7.0to0.7.1in the same bump commit.cargo package --no-verify --allow-dirty --manifest-path crates/microsoft-fast-build/Cargo.tomlproduces the expectedmicrosoft-fast-build-0.7.0.crateartifact.workflow_dispatchis enabled on the new workflow so it can be triggered manually for the first end-to-end run after merge.✅ Checklist
General
$ npm run change⏭ Next Steps
cd-github-releases.ymlviaworkflow_dispatchon a quiet day (no pending bumps) to validate the path end-to-end. Then trigger it on a day with pending changes to validate release creation. The Azure pipeline will pick up the resulting GitHub release the following night and thedeployed/<tag>marker should appear immediately after the publish. From there the nightly cron will keep the cycle going automatically.FAST.Release.PipelineTemplate.yml@fastPipelinesis happy with the separatepublish_artifacts_npm/andpublish_artifacts_crates/folders, or whether a small template tweak is needed (follow-up PR if so).