feat(cli): add --prerelease to fabro upgrade

Widens the candidate set to include prereleases, picks max semver across
stable + prereleases. Falls back to /releases/latest if no parseable
non-draft tag is returned. Conflicts with --version. Background
auto-upgrade notice remains stable-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-16 20:20:14 -04:00
parent 60cbdc97c2
commit 0b77a3efa2
No known key found for this signature in database
4 changed files with 207 additions and 1 deletions

View file

@ -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<Vec<ReleaseSummary>>` that hits `/releases` via HTTP or `gh api repos/{repo}/releases`.
- New pure `fn pick_latest_tag(releases: &[ReleaseSummary]) -> Option<String>` — 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.

View file

@ -853,6 +853,10 @@ pub(crate) struct UpgradeArgs {
#[arg(long)]
pub(crate) version: Option<String>,
/// 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,

View file

@ -73,6 +73,41 @@ impl Backend {
}
}
async fn fetch_releases(&self) -> Result<Vec<ReleaseSummary>> {
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<ReleaseSummary> = 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<ReleaseSummary> = resp.json().await?;
Ok(releases)
}
}
}
async fn download_release(&self, tag: &str, asset: &str, dest_dir: &Path) -> Result<PathBuf> {
let dest = dest_dir.join(asset);
match self {
@ -164,6 +199,31 @@ fn parse_version_from_tag(tag: &str) -> Result<Version> {
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<String> {
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);
}
}

View file

@ -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"])