mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
feat(dev): port release automation
This commit is contained in:
parent
0daf4d7b5c
commit
a03c689a44
10 changed files with 652 additions and 176 deletions
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -1778,6 +1778,7 @@ version = "0.213.0-nightly.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"chrono",
|
||||
"clap",
|
||||
"regex",
|
||||
"tempfile",
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
regex.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
422
lib/crates/fabro-dev/src/commands/release.rs
Normal file
422
lib/crates/fabro-dev/src/commands/release.rs
Normal 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(¤t_version, &new_version, &tag);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
plan.ensure_clean_worktree()?;
|
||||
plan.verify_spa_assets()?;
|
||||
plan.verify_release_tests()?;
|
||||
update_version(&cargo_toml, ¤t_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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
220
lib/crates/fabro-dev/tests/it/release.rs
Normal file
220
lib/crates/fabro-dev/tests/it/release.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue