diff --git a/docs/brainstorms/2026-04-16-fabro-upgrade-prerelease-requirements.md b/docs/brainstorms/2026-04-16-fabro-upgrade-prerelease-requirements.md new file mode 100644 index 000000000..46267be6d --- /dev/null +++ b/docs/brainstorms/2026-04-16-fabro-upgrade-prerelease-requirements.md @@ -0,0 +1,53 @@ +# `fabro upgrade --prerelease` — requirements + +## Problem + +`fabro upgrade` hits GitHub's `/releases/latest`, which excludes prereleases. Users on a prerelease (e.g. `0.204.0-beta.0`) or wanting to opt into betas must supply an exact `--version v0.X.Y-alpha.N`. There is no channel opt-in. + +## Goal + +Add `--prerelease` to `fabro upgrade` so a single invocation picks the highest semver across stable **and** prereleases. + +## Behavior + +- New bool flag `--prerelease` on `fabro upgrade`. Conflicts with `--version` (explicit version already selects any tag). +- Scope: only the explicit `fabro upgrade` command. Background auto-check (`spawn_upgrade_check` / `check_and_print_notice`) stays stable-only. +- Selection when `--prerelease` is set: + 1. Fetch `GET /repos/fabro-sh/fabro/releases` (first page, 30 items — `gh` and HTTP backends). + 2. Drop entries with `draft: true` or an unparseable `tag_name`. + 3. Pick the max by semver over the remaining set (stable + prereleases together). This means a newer stable beats an older beta; `--prerelease` *widens* the candidate set, it does not prefer prereleases. + 4. If the filtered set is empty, fall back to the existing stable-latest path (`/releases/latest`). +- Downgrade protection unchanged: + - Target < current, no explicit version → bail ("latest release … is older than installed version …, skipping"). + - Target == current, no `--force` → "Already on version …". +- `--dry-run`, `--force`, JSON output, SHA256 verify, atomic swap — all unchanged. + +## Code touchpoints + +- `lib/crates/fabro-cli/src/args.rs` — add `prerelease: bool` with `#[arg(long, conflicts_with = "version")]`. +- `lib/crates/fabro-cli/src/commands/upgrade.rs`: + - New local `ReleaseSummary { tag_name: String, draft: bool, prerelease: bool }` (serde). + - New `Backend::fetch_releases(&self) -> Result>` that hits `/releases` via HTTP or `gh api repos/{repo}/releases`. + - New pure `fn pick_latest_tag(releases: &[ReleaseSummary]) -> Option` — filters drafts + unparseable tags, returns max-semver `tag_name`. + - `run_upgrade`: when `args.prerelease`, call `fetch_releases` → `pick_latest_tag`; on `None`, fall back to `fetch_latest_release_tag`. + - `spawn_upgrade_check` / `check_and_print_notice` untouched. + +## Tests (unit, no network) + +- `pick_latest_tag`: + - mixed stable + prerelease, newest is prerelease → picks prerelease. + - mixed stable + prerelease, newest is stable → picks stable. + - all drafts → `None` (triggers fallback). + - malformed tag (`"weekly-build-3"`) alongside valid tags → skipped, valid winner returned. + - empty input → `None`. +- Clap: `fabro upgrade --prerelease --version 0.1.0` exits with `conflicts_with` error. (Existing `UpgradeArgs` tests style.) + +## Out of scope + +- Persistent channel config, env var (`FABRO_UPGRADE_CHANNEL`), `--stable` opt-out. +- Changing background upgrade notice to include prereleases. +- Docs/changelog updates beyond the `--help` string (can follow in the same PR as trivial diffs). + +## Open questions + +None. diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index bd7a31606..ecb9a0891 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -853,6 +853,10 @@ pub(crate) struct UpgradeArgs { #[arg(long)] pub(crate) version: Option, + /// Include prereleases (alpha, beta, rc) when selecting the latest version + #[arg(long, conflicts_with = "version")] + pub(crate) prerelease: bool, + /// Upgrade even if already on the target version #[arg(long)] pub(crate) force: bool, diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 158b85907..f47a40cac 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -73,6 +73,41 @@ impl Backend { } } + async fn fetch_releases(&self) -> Result> { + match self { + Self::Gh => { + let output = TokioCommand::new("gh") + .args(["api", &format!("repos/{GITHUB_REPO}/releases")]) + .output() + .await + .context("failed to run `gh api repos/.../releases`")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("gh api repos/.../releases failed: {stderr}"); + } + let releases: Vec = serde_json::from_slice(&output.stdout) + .context("failed to parse gh releases JSON")?; + Ok(releases) + } + Self::Http(client) => { + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases"); + let resp = client + .get(&url) + .send() + .await + .context("failed to fetch releases from GitHub API")?; + if !resp.status().is_success() { + bail!( + "GitHub API returned status {} when fetching releases", + resp.status() + ); + } + let releases: Vec = resp.json().await?; + Ok(releases) + } + } + } + async fn download_release(&self, tag: &str, asset: &str, dest_dir: &Path) -> Result { let dest = dest_dir.join(asset); match self { @@ -164,6 +199,31 @@ fn parse_version_from_tag(tag: &str) -> Result { Version::parse(stripped).with_context(|| format!("invalid version: {tag}")) } +// ── Release listing (for --prerelease) ───────────────────────────────────── + +#[derive(serde::Deserialize)] +struct ReleaseSummary { + tag_name: String, + #[serde(default)] + draft: bool, +} + +/// Pick the `tag_name` with the highest semver from `releases`, skipping +/// drafts and entries whose `tag_name` does not parse as semver. Returns +/// `None` if no candidate remains (caller may fall back to stable-latest). +fn pick_latest_tag(releases: &[ReleaseSummary]) -> Option { + releases + .iter() + .filter(|r| !r.draft) + .filter_map(|r| { + parse_version_from_tag(&r.tag_name) + .ok() + .map(|v| (v, &r.tag_name)) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, tag)| tag.clone()) +} + // ── SHA256 verification ──────────────────────────────────────────────────── fn verify_checksum(path: &Path, expected_hex: &str) -> Result<()> { @@ -238,7 +298,14 @@ pub(crate) async fn run_upgrade( let tag = format!("v{version}"); (version, tag) } else { - let tag = backend.fetch_latest_release_tag().await?; + let tag = if args.prerelease { + match pick_latest_tag(&backend.fetch_releases().await?) { + Some(t) => t, + None => backend.fetch_latest_release_tag().await?, + } + } else { + backend.fetch_latest_release_tag().await? + }; let version = parse_version_from_tag(&tag)?; (version, tag) }; @@ -559,4 +626,72 @@ mod tests { // Just ensure it doesn't panic; actual variant depends on environment let _backend = select_backend().await; } + + // -- Release selection -- + + fn release(tag: &str, draft: bool) -> ReleaseSummary { + ReleaseSummary { + tag_name: tag.to_string(), + draft, + } + } + + #[test] + fn pick_latest_tag_prefers_newer_prerelease_when_stable_is_older() { + let releases = [ + release("v0.204.0-beta.0", false), + release("v0.203.0", false), + release("v0.202.0", false), + ]; + assert_eq!( + pick_latest_tag(&releases).as_deref(), + Some("v0.204.0-beta.0") + ); + } + + #[test] + fn pick_latest_tag_prefers_newer_stable_over_older_prerelease() { + let releases = [ + release("v0.205.0", false), + release("v0.205.0-beta.1", false), + release("v0.204.0", false), + ]; + assert_eq!(pick_latest_tag(&releases).as_deref(), Some("v0.205.0")); + } + + #[test] + fn pick_latest_tag_filters_drafts() { + let releases = [ + release("v0.300.0", true), + release("v0.204.0-beta.0", false), + release("v0.203.0", false), + ]; + assert_eq!( + pick_latest_tag(&releases).as_deref(), + Some("v0.204.0-beta.0") + ); + } + + #[test] + fn pick_latest_tag_skips_unparseable_tags() { + let releases = [ + release("weekly-build-3", false), + release("v0.204.0-beta.0", false), + ]; + assert_eq!( + pick_latest_tag(&releases).as_deref(), + Some("v0.204.0-beta.0") + ); + } + + #[test] + fn pick_latest_tag_returns_none_when_all_drafts() { + let releases = [release("v0.300.0", true), release("v0.299.0", true)]; + assert_eq!(pick_latest_tag(&releases), None); + } + + #[test] + fn pick_latest_tag_returns_none_for_empty_input() { + assert_eq!(pick_latest_tag(&[]), None); + } } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 1ec8d43bb..8f75c3c13 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -631,6 +631,20 @@ mod tests { assert!(result.is_err(), "should reject top-level --storage-dir"); } + #[test] + fn parse_upgrade_prerelease_conflicts_with_version() { + use clap::error::ErrorKind; + let err = Cli::try_parse_from(["fabro", "upgrade", "--prerelease", "--version", "0.1.0"]) + .err() + .expect("should reject --prerelease combined with --version"); + assert_eq!( + err.kind(), + ErrorKind::ArgumentConflict, + "expected ArgumentConflict, got {:?}: {err}", + err.kind() + ); + } + #[test] fn parse_store_dump_command() { let cli = Cli::try_parse_from(["fabro", "store", "dump", "ABC123", "-o", "./out"])