feat(dev): port release automation

This commit is contained in:
Bryan Helmkamp 2026-04-24 16:00:25 -04:00
parent 0daf4d7b5c
commit a03c689a44
No known key found for this signature in database
10 changed files with 652 additions and 176 deletions

View file

@ -68,4 +68,4 @@ jobs:
git config user.email "fabro-releases[bot]@users.noreply.github.com"
git remote set-url origin \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
bin/dev/release.sh nightly
cargo dev release nightly

View file

@ -28,6 +28,9 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro`), `--compile-only` (stages `docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `scripts/refresh-fabro-spa.sh` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle.
### Release automation
- `cargo dev release [nightly]` — creates the next stable release or nightly prerelease tag. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
### Marketing site (apps/marketing)
- `cd apps/marketing && bun run dev` — start Astro dev server
- `cd apps/marketing && bun run build` — production build

1
Cargo.lock generated
View file

@ -1778,6 +1778,7 @@ version = "0.213.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"regex",
"tempfile",

View file

@ -1,167 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
printf '%s\n' "$1" >&2
exit 1
}
release_date() {
printf '%s\n' "${FABRO_RELEASE_DATE:-$(date "+%Y-%m-%d")}"
}
days_since_2026() {
local target_date="$1"
python3 -c "from datetime import date; print((date.fromisoformat('$target_date') - date(2026, 1, 1)).days)"
}
next_base_version() {
local date minor patch
date="$1"
minor=$(( $(days_since_2026 "$date") + 100 ))
patch=0
while git rev-parse "v0.${minor}.${patch}" >/dev/null 2>&1; do
patch=$((patch + 1))
done
printf '0.%s.%s\n' "$minor" "$patch"
}
validate_prerelease_label() {
local label="$1"
case "$label" in
nightly) ;;
*)
die "invalid pre-release label: $label (expected: nightly)"
;;
esac
}
compute_release_version() {
local base_version="$1"
local prerelease_label="$2"
if [[ -z "$prerelease_label" ]]; then
printf '%s\n' "$base_version"
return 0
fi
local prerelease_number=0
while git rev-parse "v${base_version}-${prerelease_label}.${prerelease_number}" >/dev/null 2>&1; do
prerelease_number=$((prerelease_number + 1))
done
printf '%s-%s.%s\n' "$base_version" "$prerelease_label" "$prerelease_number"
}
parse_args() {
DRY_RUN=0
SKIP_TESTS=0
PRERELEASE_LABEL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
--skip-tests)
SKIP_TESTS=1
;;
--help|-h)
cat <<'EOF'
Usage: bin/dev/release.sh [nightly] [--dry-run] [--skip-tests]
Create the next stable release from main, or compute a prerelease tag.
Before tagging, runs the same release-mode test smoke the CI release
workflow does for the native target, with SEGMENT_WRITE_KEY baked in so
telemetry-active code paths are exercised. This catches regressions
like "uninstall recreates ~/.fabro via telemetry" or "sender tests
assume no write key" before we burn a 60-minute CI cycle. Skip only if
you've already run `cargo nextest run --workspace --release` yourself.
EOF
exit 0
;;
--*)
die "unknown option: $1"
;;
*)
if [[ -n "$PRERELEASE_LABEL" ]]; then
die "expected at most one pre-release label"
fi
validate_prerelease_label "$1"
PRERELEASE_LABEL="$1"
;;
esac
shift
done
}
verify_spa_assets() {
local repo_root
repo_root="$(git rev-parse --show-toplevel)"
"${repo_root}/scripts/refresh-fabro-spa.sh"
if ! git -C "$repo_root" diff --exit-code -- lib/crates/fabro-spa/assets >/dev/null; then
die "fabro-spa assets are stale. Commit the refreshed assets before releasing."
fi
}
verify_release_tests() {
if [[ "$SKIP_TESTS" == "1" ]]; then
echo "--skip-tests set, skipping release-mode test smoke"
return 0
fi
echo "Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)..."
SEGMENT_WRITE_KEY="fake-for-local-smoke" \
cargo nextest run \
--workspace \
--release \
--profile ci \
--status-level slow
}
main() {
parse_args "$@"
verify_spa_assets
verify_release_tests
local repo_root cargo_toml current_version base_version new_version tag
repo_root="$(git rev-parse --show-toplevel)"
cargo_toml="${repo_root}/Cargo.toml"
current_version=$(grep -m1 '^version = ' "$cargo_toml" | sed 's/version = "\(.*\)"/\1/')
echo "Current version: $current_version"
base_version=$(next_base_version "$(release_date)")
new_version=$(compute_release_version "$base_version" "$PRERELEASE_LABEL")
tag="v$new_version"
echo "Releasing $new_version (tag $tag)"
if [[ "$DRY_RUN" == "1" ]]; then
return 0
fi
sed -i.bak "s/^version = \"$current_version\"/version = \"$new_version\"/" "$cargo_toml"
rm "${cargo_toml}.bak"
echo "Updated $cargo_toml"
cargo update --workspace
echo "Updated Cargo.lock"
git add "$cargo_toml" Cargo.lock
git commit -m "Bump version to $new_version"
git tag -a "$tag" -m "$tag"
git push origin main "$tag"
echo ""
echo "Released $tag"
echo "Watch the build: https://github.com/fabro-sh/fabro/actions"
}
main "$@"

View file

@ -666,7 +666,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen
---
- [ ] **Unit 5.4: Port `release.sh` to `fabro-dev release`**
- [x] **Unit 5.4: Port `release.sh` to `fabro-dev release`**
**Goal:** Replace `bin/dev/release.sh` with a subcommand.

View file

@ -15,6 +15,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
regex.workspace = true
tracing-subscriber.workspace = true

View file

@ -3,12 +3,11 @@ use clap::Args;
mod check_boundary;
mod docker_build;
mod release;
pub(crate) use check_boundary::{CheckBoundaryArgs, check_boundary};
pub(crate) use docker_build::{DockerBuildArgs, docker_build};
#[derive(Debug, Args)]
pub(crate) struct ReleaseArgs;
pub(crate) use release::{ReleaseArgs, release};
#[derive(Debug, Args)]
pub(crate) struct RefreshSpaArgs;
@ -16,10 +15,6 @@ pub(crate) struct RefreshSpaArgs;
#[derive(Debug, Args)]
pub(crate) struct CheckSpaBudgetsArgs;
pub(crate) fn release(_args: ReleaseArgs) -> Result<()> {
not_yet_implemented("release")
}
pub(crate) fn refresh_spa(_args: RefreshSpaArgs) -> Result<()> {
not_yet_implemented("refresh-spa")
}

View file

@ -0,0 +1,422 @@
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use anyhow::{Context, Result, bail};
use chrono::{Local, NaiveDate};
use clap::{Args, ValueEnum};
const RELEASE_EPOCH: &str = "2026-01-01";
const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke";
#[derive(Debug, Args)]
pub(crate) struct ReleaseArgs {
/// Pre-release label to create.
#[arg(value_enum)]
prerelease_label: Option<PrereleaseLabel>,
/// Print planned release steps without mutating git or running Cargo.
#[arg(long)]
dry_run: bool,
/// Skip the release-mode test smoke.
#[arg(long)]
skip_tests: bool,
/// Release date to use for version computation.
#[arg(long, value_name = "YYYY-MM-DD", env = "FABRO_RELEASE_DATE")]
release_date: Option<NaiveDate>,
/// Repository root to release.
#[arg(long, hide = true)]
root: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum PrereleaseLabel {
Nightly,
}
impl fmt::Display for PrereleaseLabel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nightly => formatter.write_str("nightly"),
}
}
}
struct ReleasePlan {
prerelease_label: Option<PrereleaseLabel>,
release_date: NaiveDate,
dry_run: bool,
skip_tests: bool,
root: PathBuf,
}
#[expect(
clippy::print_stdout,
reason = "dev release command reports progress and dry-run commands directly"
)]
pub(crate) fn release(args: ReleaseArgs) -> Result<()> {
let plan = ReleasePlan {
prerelease_label: args.prerelease_label,
release_date: args
.release_date
.unwrap_or_else(|| Local::now().date_naive()),
dry_run: args.dry_run,
skip_tests: args.skip_tests,
root: args.root.unwrap_or_else(workspace_root),
};
let cargo_toml = plan.root.join("Cargo.toml");
let current_version = read_current_version(&cargo_toml)?;
println!("Current version: {current_version}");
let base_version = plan.next_base_version()?;
let new_version = plan.compute_release_version(&base_version)?;
let tag = format!("v{new_version}");
println!("Releasing {new_version} (tag {tag})");
if plan.dry_run {
plan.print_dry_run(&current_version, &new_version, &tag);
return Ok(());
}
plan.ensure_clean_worktree()?;
plan.verify_spa_assets()?;
plan.verify_release_tests()?;
update_version(&cargo_toml, &current_version, &new_version)?;
println!("Updated {}", cargo_toml.display());
plan.run_command(
&PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
)?;
println!("Updated Cargo.lock");
plan.run_command(
&PlannedCommand::new("git")
.arg("add")
.arg("Cargo.toml")
.arg("Cargo.lock"),
)?;
plan.run_command(
&PlannedCommand::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Bump version to {new_version}")),
)?;
plan.run_command(
&PlannedCommand::new("git")
.arg("tag")
.arg("-a")
.arg(&tag)
.arg("-m")
.arg(&tag),
)?;
plan.run_command(
&PlannedCommand::new("git")
.arg("push")
.arg("origin")
.arg("main")
.arg(&tag),
)?;
println!();
println!("Released {tag}");
println!("Watch the build: https://github.com/fabro-sh/fabro/actions");
Ok(())
}
impl ReleasePlan {
fn next_base_version(&self) -> Result<String> {
let epoch = NaiveDate::parse_from_str(RELEASE_EPOCH, "%Y-%m-%d")
.expect("release epoch should be a valid date");
let days_since_epoch = self.release_date.signed_duration_since(epoch).num_days();
if days_since_epoch < 0 {
bail!(
"release date {} predates {RELEASE_EPOCH}",
self.release_date
);
}
let minor = days_since_epoch + 100;
let mut patch = 0;
loop {
let version = format!("0.{minor}.{patch}");
if !self.tag_exists(&format!("v{version}"))? {
return Ok(version);
}
patch += 1;
}
}
fn compute_release_version(&self, base_version: &str) -> Result<String> {
let Some(prerelease_label) = self.prerelease_label else {
return Ok(base_version.to_string());
};
let mut prerelease_number = 0;
loop {
let version = format!("{base_version}-{prerelease_label}.{prerelease_number}");
if !self.tag_exists(&format!("v{version}"))? {
return Ok(version);
}
prerelease_number += 1;
}
}
fn ensure_clean_worktree(&self) -> Result<()> {
let output = self.capture_command(
&PlannedCommand::new("git")
.arg("status")
.arg("--porcelain")
.arg("--untracked-files=all"),
)?;
if !output.status.success() {
bail!(
"failed to inspect working tree: {}",
String::from_utf8_lossy(&output.stderr)
);
}
if !output.stdout.is_empty() {
bail!("working tree is dirty; commit or stash changes before releasing");
}
Ok(())
}
fn verify_spa_assets(&self) -> Result<()> {
self.run_command(&Self::refresh_spa_command())?;
let output = self.capture_command(&Self::spa_assets_diff_command())?;
if !output.status.success() {
bail!("fabro-spa assets are stale. Commit the refreshed assets before releasing.");
}
Ok(())
}
#[expect(
clippy::print_stdout,
reason = "dev release command reports release test progress directly"
)]
fn verify_release_tests(&self) -> Result<()> {
if self.skip_tests {
println!("--skip-tests set, skipping release-mode test smoke");
return Ok(());
}
println!("Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)...");
self.run_command(&Self::release_tests_command())
}
#[expect(
clippy::print_stdout,
reason = "dev release command reports dry-run commands directly"
)]
fn print_dry_run(&self, current_version: &str, new_version: &str, tag: &str) {
println!("DRY RUN: would verify SPA assets:");
println!("{}", Self::refresh_spa_command().to_shell_line());
println!("{}", Self::spa_assets_diff_command().to_shell_line());
if self.skip_tests {
println!("--skip-tests set, would skip release-mode test smoke");
} else {
println!("DRY RUN: would run release-mode test smoke:");
println!("{}", Self::release_tests_command().to_shell_line());
}
println!("DRY RUN: would update Cargo.toml version {current_version} -> {new_version}");
for command in [
PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
PlannedCommand::new("git")
.arg("add")
.arg("Cargo.toml")
.arg("Cargo.lock"),
PlannedCommand::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Bump version to {new_version}")),
PlannedCommand::new("git")
.arg("tag")
.arg("-a")
.arg(tag)
.arg("-m")
.arg(tag),
PlannedCommand::new("git")
.arg("push")
.arg("origin")
.arg("main")
.arg(tag),
] {
println!("{}", command.to_shell_line());
}
}
fn refresh_spa_command() -> PlannedCommand {
PlannedCommand::new("scripts/refresh-fabro-spa.sh")
}
fn spa_assets_diff_command() -> PlannedCommand {
PlannedCommand::new("git")
.arg("diff")
.arg("--exit-code")
.arg("--")
.arg("lib/crates/fabro-spa/assets")
}
fn release_tests_command() -> PlannedCommand {
PlannedCommand::new("cargo")
.env("SEGMENT_WRITE_KEY", RELEASE_TEST_SEGMENT_WRITE_KEY)
.arg("nextest")
.arg("run")
.arg("--workspace")
.arg("--release")
.arg("--profile")
.arg("ci")
.arg("--status-level")
.arg("slow")
}
fn tag_exists(&self, tag: &str) -> Result<bool> {
let output = self.capture_command(
&PlannedCommand::new("git")
.arg("rev-parse")
.arg("--verify")
.arg("--quiet")
.arg(format!("refs/tags/{tag}")),
)?;
Ok(output.status.success())
}
fn run_command(&self, planned: &PlannedCommand) -> Result<()> {
let status = command(planned)
.current_dir(&self.root)
.status()
.with_context(|| format!("running {}", planned.to_shell_line()))?;
if !status.success() {
bail!("command failed with {status}: {}", planned.to_shell_line());
}
Ok(())
}
fn capture_command(&self, planned: &PlannedCommand) -> Result<Output> {
command(planned)
.current_dir(&self.root)
.output()
.with_context(|| format!("running {}", planned.to_shell_line()))
}
}
#[expect(
clippy::disallowed_methods,
reason = "dev release reads the workspace manifest synchronously"
)]
fn read_current_version(cargo_toml: &Path) -> Result<String> {
let contents = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("reading {}", cargo_toml.display()))?;
for line in contents.lines().map(str::trim) {
let Some(rest) = line.strip_prefix("version = \"") else {
continue;
};
let Some(version) = rest.strip_suffix('"') else {
continue;
};
return Ok(version.to_string());
}
bail!(
"could not find workspace package version in {}",
cargo_toml.display()
)
}
#[expect(
clippy::disallowed_methods,
reason = "dev release updates the workspace manifest synchronously"
)]
fn update_version(cargo_toml: &Path, current_version: &str, new_version: &str) -> Result<()> {
let contents = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("reading {}", cargo_toml.display()))?;
let needle = format!("version = \"{current_version}\"");
let replacement = format!("version = \"{new_version}\"");
if !contents.contains(&needle) {
bail!(
"could not find current version {current_version} in {}",
cargo_toml.display()
);
}
std::fs::write(cargo_toml, contents.replacen(&needle, &replacement, 1))
.with_context(|| format!("writing {}", cargo_toml.display()))
}
#[expect(
clippy::disallowed_methods,
reason = "dev release builds synchronous subprocess commands"
)]
fn command(planned: &PlannedCommand) -> Command {
let mut command = Command::new(&planned.program);
command.args(&planned.args);
for (key, value) in &planned.env {
command.env(key, value);
}
command
}
struct PlannedCommand {
program: String,
args: Vec<String>,
env: Vec<(String, String)>,
}
impl PlannedCommand {
fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
env: Vec::new(),
}
}
fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push((key.into(), value.into()));
self
}
fn to_shell_line(&self) -> String {
self.env
.iter()
.map(|(key, value)| format!("{}={}", shell_arg(key), shell_arg(value)))
.chain(std::iter::once(shell_arg(&self.program)))
.chain(self.args.iter().map(shell_arg))
.collect::<Vec<_>>()
.join(" ")
}
}
fn shell_arg(arg: impl AsRef<str>) -> String {
let arg = arg.as_ref();
if arg
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "_-./:=@".contains(ch))
{
return arg.to_string();
}
format!("'{}'", arg.replace('\'', "'\\''"))
}
fn workspace_root() -> PathBuf {
let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
root.pop();
root.pop();
root.pop();
root
}

View file

@ -3,6 +3,7 @@ use std::process::{Command, Output};
mod check_boundary;
mod docker_build;
mod release;
fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")

View file

@ -0,0 +1,220 @@
use std::path::Path;
use std::process::Command;
fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")
}
fn output_text(bytes: &[u8]) -> String {
String::from_utf8(bytes.to_vec()).expect("command output should be valid utf-8")
}
#[expect(
clippy::disallowed_methods,
reason = "integration tests stage temporary release fixture repositories with sync std::fs::write"
)]
fn write_file(path: &Path, contents: &str) {
std::fs::write(path, contents).expect("writing fixture file");
}
#[expect(
clippy::disallowed_methods,
reason = "integration tests intentionally shell out to git in temporary fixture repositories"
)]
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git should run");
assert!(
output.status.success(),
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
output_text(&output.stdout),
output_text(&output.stderr)
);
}
fn release_fixture() -> tempfile::TempDir {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
&fixture.path().join("Cargo.toml"),
r#"[workspace]
members = []
[workspace.package]
version = "0.1.0"
"#,
);
git(fixture.path(), &["init"]);
git(fixture.path(), &["config", "user.name", "Release Test"]);
git(fixture.path(), &[
"config",
"user.email",
"release-test@example.com",
]);
git(fixture.path(), &["add", "Cargo.toml"]);
git(fixture.path(), &["commit", "-m", "initial"]);
fixture
}
#[test]
fn help_lists_release_flags() {
let output = fabro_dev()
.args(["release", "--help"])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
for flag in ["--dry-run", "--skip-tests", "--release-date"] {
assert!(
stdout.contains(flag),
"release help should list {flag}:\n{stdout}"
);
}
}
#[test]
fn dry_run_computes_stable_version_from_date() {
let fixture = release_fixture();
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("Releasing 0.100.0 (tag v0.100.0)"),
"dry-run should compute base version from date:\n{stdout}"
);
assert!(
stdout.contains("git tag -a v0.100.0 -m v0.100.0"),
"dry-run should print release tag command:\n{stdout}"
);
}
#[test]
fn dry_run_increments_existing_prerelease_number() {
let fixture = release_fixture();
git(fixture.path(), &["tag", "v0.100.0-nightly.0"]);
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
"nightly",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("Releasing 0.100.0-nightly.1 (tag v0.100.0-nightly.1)"),
"dry-run should increment existing nightly tag:\n{stdout}"
);
}
#[test]
fn invalid_prerelease_label_fails_with_clap_error() {
let output = fabro_dev()
.args(["release", "beta"])
.assert()
.failure()
.code(2)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("invalid value 'beta'"),
"invalid prerelease label should be rejected by clap:\n{stderr}"
);
}
#[test]
fn dry_run_reports_skip_tests_without_running_release_tests() {
let fixture = release_fixture();
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--dry-run",
"--skip-tests",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("--skip-tests set, would skip release-mode test smoke"),
"dry-run should report skip-tests behavior:\n{stdout}"
);
assert!(
!stdout.contains("cargo nextest run"),
"dry-run with skip-tests should not print release test command:\n{stdout}"
);
}
#[test]
fn dirty_worktree_errors_unless_dry_run() {
let fixture = release_fixture();
write_file(&fixture.path().join("dirty.txt"), "dirty\n");
let output = fabro_dev()
.args([
"release",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--release-date",
"2026-01-01",
"--skip-tests",
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("working tree is dirty"),
"dirty non-dry-run release should fail before mutating:\n{stderr}"
);
}