mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
60cbdc97c2
commit
0b77a3efa2
4 changed files with 207 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue