From 4ac54c610d6db7c5e5a8dd31542022a8f83b5209 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:19:31 +0800 Subject: [PATCH] bakery: add stable/beta/dev build tracks Adds a track concept to bakery (separate from the existing bakery/pacman distribution channel): stable (unchanged tag-triggered releases), beta (deliberate beta-v* tag promotion), and dev (published on every push to dev). Each track gets its own signed index + artifact tree under dl.breadway.dev so stable's paths and existing installs are untouched. - bakery: new Track type, a global track preference in installed.json (defaults to stable via serde, no migration needed), `bakery track show`/`set`, a BAKERY_INDEX_BASE_URL override for testing, and a real semver comparison in `update` (was a plain string-equality check before). ANSI-colored/aligned CLI output (TTY + NO_COLOR aware). - gen-index.sh: TRACK env var selects which subtree to read/write. - CI: dev-bakery.yml/beta-bakery.yml/dev-bread-theme.yml/beta-bread-theme.yml publish those two products on the new tracks; dev/beta skip the GitHub Release upload step (no per-commit release spam). - docs/release-channels.md documents the three-track policy. --- .forgejo/workflows/beta-bakery.yml | 61 ++++++++++ .forgejo/workflows/beta-bread-theme.yml | 56 +++++++++ .forgejo/workflows/dev-bakery.yml | 80 ++++++++++++ .forgejo/workflows/dev-bread-theme.yml | 69 +++++++++++ Cargo.lock | 1 + Cargo.toml | 1 + bakery/Cargo.toml | 1 + bakery/src/doctor.rs | 24 ++-- bakery/src/main.rs | 155 ++++++++++++++++++++---- bakery/src/manifest.rs | 106 +++++++++++++--- bakery/src/state.rs | 27 +++++ bakery/src/track.rs | 90 ++++++++++++++ bakery/src/ui.rs | 60 +++++++++ docs/release-channels.md | 59 +++++++-- scripts/gen-index.sh | 48 ++++++-- 15 files changed, 771 insertions(+), 67 deletions(-) create mode 100644 .forgejo/workflows/beta-bakery.yml create mode 100644 .forgejo/workflows/beta-bread-theme.yml create mode 100644 .forgejo/workflows/dev-bakery.yml create mode 100644 .forgejo/workflows/dev-bread-theme.yml create mode 100644 bakery/src/track.rs create mode 100644 bakery/src/ui.rs diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml new file mode 100644 index 0000000..ca2f49c --- /dev/null +++ b/.forgejo/workflows/beta-bakery.yml @@ -0,0 +1,61 @@ +name: beta bakery + +# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate +# promotion step (you pick the version string and the commit), distinct from +# dev-bakery.yml's automatic build-on-every-push. See docs/release-channels.md. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked -p bakery + + - name: test + run: cd src && cargo test --release --locked -p bakery + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bakery/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" > "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" > "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" + strip "${PKG_DIR}/bread-theme-x86_64" + sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bread-theme/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" bool { /// Returns true if all *required* deps are satisfied. pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {package_name}: no system deps required"); + println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" error running doctor for {package_name}: {e}"); + eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); false } Ok(rep) => { for warn in &rep.warnings { eprintln!( - " {package_name}: optional dep not found: {warn} \ - (install for full functionality)" + " {}", + ui::style( + &format!( + "{package_name}: optional dep not found: {warn} \ + (install for full functionality)" + ), + ui::YELLOW + ) ); } if rep.missing.is_empty() { - println!(" {package_name}: all required system deps satisfied"); + println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); true } else { eprintln!( - " {package_name}: missing system deps: {}", - rep.missing.join(", ") + " {}", + ui::fail(&format!( + "{package_name}: missing system deps: {}", + rep.missing.join(", ") + )) ); eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); false diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 821f55a..8a16b2c 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -3,11 +3,14 @@ mod download; mod install; mod manifest; mod state; +mod track; +mod ui; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use std::collections::HashSet; use std::path::PathBuf; +use track::Track; #[derive(Parser)] #[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] @@ -54,6 +57,20 @@ enum Cmd { /// Package to check; omit to check all installed packages package: Option, }, + /// View or switch which build track bakery follows (stable/beta/dev) + Track { + #[command(subcommand)] + action: TrackCmd, + }, +} + +#[derive(Subcommand)] +enum TrackCmd { + /// Show the currently selected track + Show, + /// Switch tracks. Only changes the preference — run `bakery update --all` + /// afterwards to actually install builds from the new track. + Set { track: Track }, } fn default_bin_dir() -> PathBuf { @@ -65,23 +82,52 @@ fn default_bin_dir() -> PathBuf { fn main() -> Result<()> { let cli = Cli::parse(); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); + let track = state::State::load()?.track; match cli.command { Cmd::Install { packages } => { - let index = manifest::load(true)?; + let index = manifest::load(true, track)?; for pkg in &packages { cmd_install(&index, pkg, &bin_dir)?; } Ok(()) } Cmd::Remove { package } => cmd_remove(&package, &bin_dir), - Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), - Cmd::List { installed } => cmd_list(installed), - Cmd::Info { package } => cmd_info(&package), - Cmd::Doctor { package } => cmd_doctor(package.as_deref()), + Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track), + Cmd::List { installed } => cmd_list(installed, track), + Cmd::Info { package } => cmd_info(&package, track), + Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track), + Cmd::Track { action } => cmd_track(action), } } +fn cmd_track(action: TrackCmd) -> Result<()> { + let mut state = state::State::load()?; + match action { + TrackCmd::Show => { + println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); + } + TrackCmd::Set { track } => { + if state.track == track { + println!("already on track {track}"); + return Ok(()); + } + // Fail fast on a bad/unreachable track rather than silently + // recording a preference bakery can't actually serve. + manifest::load(true, track) + .with_context(|| format!("could not validate {track} track, not switching"))?; + state.set_track(track); + state.save()?; + println!( + "switched to {} — run 'bakery update --all' to install {} builds", + ui::style(track.as_str(), ui::CYAN), + track + ); + } + } + Ok(()) +} + fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { let mut visited = HashSet::new(); install_with_deps(index, name, bin_dir, &mut visited) @@ -130,8 +176,8 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> { install::remove_package(name, bin_dir) } -fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(true)?; +fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: Track) -> Result<()> { + let index = manifest::load(true, track)?; let state = state::State::load()?; let targets: Vec = if all || name.is_none() { @@ -162,14 +208,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul } }; - if installed.version == latest.version { - println!("{pkg_name} is already at {}", installed.version); + if !is_newer(&installed.version, &latest.version) { + println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN)); continue; } println!( - "updating {pkg_name} {} → {}", - installed.version, latest.version + "updating {pkg_name} {} {} {}", + ui::style(&installed.version, ui::DIM), + ui::style("→", ui::CYAN), + ui::style(&latest.version, ui::BOLD) ); let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { @@ -204,7 +252,28 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul Ok(()) } -fn cmd_list(installed_only: bool) -> Result<()> { +/// Is `latest` newer than `installed`? Real semver comparison — the +/// previous plain string-equality check couldn't tell "different" from +/// "actually newer", so it would happily "update" a package to a lexically +/// different but not-newer version. Falls back to a simple inequality check +/// (with a warning) for any version string that isn't valid semver, rather +/// than hard-erroring on packages built before this convention existed. +fn is_newer(installed: &str, latest: &str) -> bool { + match (semver::Version::parse(installed), semver::Version::parse(latest)) { + (Ok(i), Ok(l)) => l > i, + _ => { + if installed != latest { + eprintln!( + " warning: '{installed}' or '{latest}' is not valid semver, \ + falling back to a plain inequality check" + ); + } + installed != latest + } + } +} + +fn cmd_list(installed_only: bool, track: Track) -> Result<()> { let state = state::State::load()?; if installed_only { @@ -217,35 +286,39 @@ fn cmd_list(installed_only: bool) -> Result<()> { return Ok(()); } - let index = manifest::load(false)?; + if !matches!(track, Track::Stable) { + println!("tracking:{}\n", ui::track_badge(track)); + } + + let index = manifest::load(false, track)?; let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); names.sort(); for name in names { let pkg = &index.packages[name]; let tag = if state.is_installed(name) { - format!(" [installed {}]", state.packages[name].version) + ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN) } else { String::new() }; - println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag); + println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); } Ok(()) } -fn cmd_info(name: &str) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_info(name: &str, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let pkg = index .get(name) .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; let state = state::State::load()?; let status = if let Some(inst) = state.packages.get(name) { - format!("installed ({})", inst.version) + ui::style(&format!("installed ({})", inst.version), ui::GREEN) } else { - "not installed".to_string() + ui::style("not installed", ui::DIM) }; - println!("{} {}", pkg.name, pkg.version); + println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); println!(" {}", pkg.description); println!(" status: {status}"); println!( @@ -278,8 +351,8 @@ fn cmd_info(name: &str) -> Result<()> { Ok(()) } -fn cmd_doctor(name: Option<&str>) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let state = state::State::load()?; let targets: Vec = match name { @@ -310,7 +383,41 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { } if all_ok { - println!("all checks passed"); + println!("{}", ui::ok("all checks passed")); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_detects_real_semver_increase() { + assert!(is_newer("0.3.1", "0.3.2")); + assert!(is_newer("0.3.1", "0.4.0")); + assert!(!is_newer("0.3.2", "0.3.1")); + } + + #[test] + fn is_newer_false_when_equal() { + assert!(!is_newer("0.3.1", "0.3.1")); + } + + #[test] + fn is_newer_orders_dev_prereleases_within_a_track() { + // Two dev builds of the same upcoming patch, ordered by their + // timestamp+sha build suffix. + assert!(is_newer( + "0.3.2-dev.20260722120000+aaa1111", + "0.3.2-dev.20260722130000+bbb2222" + )); + } + + #[test] + fn is_newer_falls_back_to_inequality_on_unparseable_versions() { + // Pre-semver version strings should never hard-fail an update check. + assert!(is_newer("weird-version-1", "weird-version-2")); + assert!(!is_newer("weird-version-1", "weird-version-1")); + } +} diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 35a9bbc..248b715 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -1,13 +1,34 @@ +use crate::track::Track; use anyhow::{bail, Context, Result}; use minisign_verify::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; -const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json"; -const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig"; +const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev"; const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); +/// The `https://dl.breadway.dev` base can be overridden for local/staging +/// testing (e.g. serving a fake index from `python3 -m http.server`) without +/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override. +fn base_url() -> String { + std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) +} + +/// Index URL for `track`. `Stable` keeps the exact pre-track path +/// (`{base}/index.json`) so existing infra and warm caches are unaffected; +/// `Beta`/`Dev` live under a track-prefixed subpath. +fn primary_url(track: Track) -> String { + match track { + Track::Stable => format!("{}/index.json", base_url()), + Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()), + } +} + +fn sig_url(track: Track) -> String { + format!("{}.minisig", primary_url(track)) +} + /// The bakery index-signing public key. /// /// The matching secret key is used offline (never on this machine, never in @@ -120,8 +141,8 @@ impl Index { } } -/// Load the manifest, using the on-disk cache when it is fresh enough. -/// Always fetches if `force_refresh` is true. +/// Load the manifest for `track`, using the on-disk cache when it is fresh +/// enough. Always fetches if `force_refresh` is true. /// /// Every path — fresh fetch or cached read — verifies the minisign /// signature over the raw `index.json` bytes before the JSON is parsed or @@ -130,12 +151,12 @@ impl Index { /// (possibly tampered, possibly just stale-format) cache and triggers one /// re-fetch from the network rather than bricking the CLI outright; if the /// freshly fetched copy also fails to verify, that's a hard error. -pub fn load(force_refresh: bool) -> Result { - let cache_path = cache_path(); +pub fn load(force_refresh: bool, track: Track) -> Result { + let cache_path = cache_path(track); let sig_cache_path = sig_cache_path(&cache_path); if !force_refresh && cache_is_fresh(&cache_path) { - match read_and_verify_cache(&cache_path, &sig_cache_path) { + match read_and_verify_cache(&cache_path, &sig_cache_path, track) { Ok(index) => return Ok(index), Err(err) => { eprintln!( @@ -145,14 +166,20 @@ pub fn load(force_refresh: bool) -> Result { } } - fetch_and_cache(&cache_path, &sig_cache_path) + fetch_and_cache(&cache_path, &sig_cache_path, track) } -fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { +fn read_and_verify_cache( + cache_path: &PathBuf, + sig_cache_path: &PathBuf, + track: Track, +) -> Result { let bytes = std::fs::read(cache_path).context("reading cached index")?; let sig_text = std::fs::read_to_string(sig_cache_path) .context("reading cached index.json.minisig (cache predates signing support)")?; - verify_index_signature(&bytes, &sig_text)?; + verify_index_signature(&bytes, &sig_text).with_context(|| { + format!("cached {track} index failed signature verification") + })?; serde_json::from_slice(&bytes).context("parsing cached index") } @@ -163,13 +190,18 @@ fn cache_is_fresh(path: &PathBuf) -> bool { .unwrap_or(false) } -fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { - let bytes = fetch_bytes(PRIMARY_URL)?; - let sig_text = fetch_text(SIG_URL).context( +fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result { + let bytes = fetch_bytes(&primary_url(track)).with_context(|| { + format!( + "fetching {track} index — has a {track} build been published yet? \ + run 'bakery track set stable' to switch back" + ) + })?; + let sig_text = fetch_text(&sig_url(track)).context( "fetching index.json.minisig — the index must be signed before it can be trusted", )?; verify_index_signature(&bytes, &sig_text) - .context("freshly fetched index.json failed signature verification")?; + .with_context(|| format!("freshly fetched {track} index failed signature verification"))?; if let Some(dir) = cache_path.parent() { std::fs::create_dir_all(dir)?; @@ -193,10 +225,19 @@ fn fetch_text(url: &str) -> Result { .context("reading response body") } -pub fn cache_path() -> PathBuf { +/// Cache filename for `track`. `Stable` keeps the pre-track filename +/// (`index.json`) so an existing warm cache survives an upgrade to a +/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching +/// tracks doesn't clobber each other's cache. +pub fn cache_path(track: Track) -> PathBuf { + let file_name = match track { + Track::Stable => "index.json".to_string(), + Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()), + }; dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("~/.cache")) - .join("bakery/index.json") + .join("bakery") + .join(file_name) } /// Download a binary blob from `primary_url`, falling back to `fallback_url` @@ -276,4 +317,37 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ // it must at least parse as a valid minisign public key. PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key"); } + + #[test] + fn stable_cache_path_matches_pre_track_filename() { + // Must stay exactly "index.json" so an existing warm cache from a + // pre-track bakery binary is still used after an upgrade. + assert_eq!( + cache_path(Track::Stable).file_name().unwrap(), + "index.json" + ); + } + + #[test] + fn beta_and_dev_cache_paths_are_distinct_siblings() { + let stable = cache_path(Track::Stable); + let beta = cache_path(Track::Beta); + let dev = cache_path(Track::Dev); + assert_ne!(stable, beta); + assert_ne!(stable, dev); + assert_ne!(beta, dev); + assert_eq!(beta.parent(), stable.parent()); + assert_eq!(dev.parent(), stable.parent()); + } + + #[test] + fn stable_url_has_no_track_prefix() { + assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url())); + } + + #[test] + fn beta_and_dev_urls_are_track_prefixed() { + assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); + assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); + } } diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 92b9aa9..7bf0ad8 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -1,3 +1,4 @@ +use crate::track::Track; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -14,6 +15,11 @@ pub struct InstalledPackage { #[derive(Debug, Default, Deserialize, Serialize)] pub struct State { + // `#[serde(default)]` lets an installed.json written by a pre-track + // bakery binary deserialize straight into Track::Stable with no + // migration step. + #[serde(default)] + pub track: Track, pub packages: HashMap, } @@ -52,6 +58,10 @@ impl State { pub fn remove(&mut self, name: &str) -> Option { self.packages.remove(name) } + + pub fn set_track(&mut self, track: Track) { + self.track = track; + } } fn state_path() -> PathBuf { @@ -101,6 +111,23 @@ mod tests { assert!(state.remove("nope").is_none()); } + #[test] + fn track_defaults_to_stable_on_old_shape_json() { + // Simulates installed.json written before the track field existed. + let old_shape = r#"{"packages":{}}"#; + let state: State = serde_json::from_str(old_shape).unwrap(); + assert_eq!(state.track, Track::Stable); + } + + #[test] + fn set_track_updates_and_roundtrips() { + let mut state = State::default(); + state.set_track(Track::Dev); + let json = serde_json::to_string(&state).unwrap(); + let restored: State = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.track, Track::Dev); + } + #[test] fn json_roundtrip() { let mut state = State::default(); diff --git a/bakery/src/track.rs b/bakery/src/track.rs new file mode 100644 index 0000000..1c16ce5 --- /dev/null +++ b/bakery/src/track.rs @@ -0,0 +1,90 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Which build of a package bakery follows: the tagged stable release, a +/// deliberately-promoted beta, or the continuously-published `dev` branch +/// build. Not to be confused with the *distribution* channel (bakery vs. +/// pacman) documented in `docs/release-channels.md` — that's an orthogonal, +/// pre-existing use of the word "channel", which is why this is called a +/// "track" instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum Track { + Stable, + Beta, + Dev, +} + +impl Default for Track { + fn default() -> Self { + Track::Stable + } +} + +impl Track { + pub fn as_str(&self) -> &'static str { + match self { + Track::Stable => "stable", + Track::Beta => "beta", + Track::Dev => "dev", + } + } +} + +impl fmt::Display for Track { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Track { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stable" => Ok(Track::Stable), + "beta" => Ok(Track::Beta), + "dev" => Ok(Track::Dev), + other => Err(format!("unknown track '{other}' — expected stable, beta, or dev")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_stable() { + assert_eq!(Track::default(), Track::Stable); + } + + #[test] + fn display_roundtrips_through_from_str() { + for track in [Track::Stable, Track::Beta, Track::Dev] { + let s = track.to_string(); + assert_eq!(s.parse::().unwrap(), track); + } + } + + #[test] + fn from_str_is_case_insensitive() { + assert_eq!("DEV".parse::().unwrap(), Track::Dev); + assert_eq!("Beta".parse::().unwrap(), Track::Beta); + } + + #[test] + fn from_str_rejects_unknown() { + assert!("nightly".parse::().is_err()); + } + + #[test] + fn json_roundtrip_uses_lowercase() { + let json = serde_json::to_string(&Track::Dev).unwrap(); + assert_eq!(json, "\"dev\""); + let back: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(back, Track::Dev); + } +} diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs new file mode 100644 index 0000000..8f99505 --- /dev/null +++ b/bakery/src/ui.rs @@ -0,0 +1,60 @@ +use crate::track::Track; +use std::io::IsTerminal; + +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RED: &str = "\x1b[31m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const CYAN: &str = "\x1b[36m"; +pub const MAGENTA: &str = "\x1b[35m"; + +/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't +/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI +/// unconditionally, which leaks escape codes into piped/logged output; this +/// is the hardening fix for that gap. +pub fn colors_enabled() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +} + +pub fn style(s: &str, code: &str) -> String { + if colors_enabled() { + format!("{code}{s}{RESET}") + } else { + s.to_string() + } +} + +/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the +/// common-case output is unchanged. +pub fn track_badge(track: Track) -> String { + match track { + Track::Stable => String::new(), + Track::Beta => format!(" {}", style("[beta]", YELLOW)), + Track::Dev => format!(" {}", style("[dev]", MAGENTA)), + } +} + +pub fn ok(s: &str) -> String { + style(&format!("✓ {s}"), GREEN) +} + +pub fn fail(s: &str) -> String { + style(&format!("✗ {s}"), RED) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_badge_is_empty() { + assert_eq!(track_badge(Track::Stable), ""); + } + + #[test] + fn dev_badge_is_nonempty() { + assert!(!track_badge(Track::Dev).is_empty()); + } +} diff --git a/docs/release-channels.md b/docs/release-channels.md index 952c9c2..d717ff4 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -50,6 +50,44 @@ release or package workflow — just the repo itself, e.g. breadarr today). binary, via its own `release-iso.yml`. It is never on either channel and should never carry a `bakery.toml` or `PKGBUILD`. +## Build tracks (stable/beta/dev) — orthogonal to channels + +Within the **bakery channel only**, a repo can additionally publish up to +three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged), +`beta` (a deliberate promotion triggered by a `beta-v*` tag), and `dev` +(published automatically on every push to the `dev` branch). Don't confuse +"track" with "channel" above — channel is *how* a binary reaches a user +(bakery vs. pacman); track is *which build* of a bakery-channel package they +get. + +Each track lives in its own subtree so they never collide: + +| Track | Index URL | Artifact root | Trigger | +|---|---|---|---| +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push tag `beta-v*` | +| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `dev` | + +`scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select +which subtree it reads/writes — every existing stable release workflow needs +zero changes. Dev/beta builds skip the GitHub Release upload step entirely +(no release-per-commit spam for dev, and beta doesn't need a GitHub mirror +either) — `dl.breadway.dev` is their only distribution point. + +Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` / +`beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the +repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap +the repo/binary names the same way the checklist below describes for +`release.yml`. Not every bakery-channel repo needs beta/dev on day one — +`gen-index.sh` silently skips any product with no release dir under a given +track's tree, same as it already does for an unreleased product on stable. + +Client side: `bakery track show` / `bakery track set ` +remembers a global track preference (`~/.local/state/bakery/installed.json`) +and validates the target track's index is reachable and signed before +switching — it never auto-reinstalls on switch, run `bakery update --all` +afterwards. + ## mirror.yml is not part of this policy Every repo previously carried its own `.forgejo/workflows/mirror.yml` doing @@ -82,13 +120,14 @@ missing it; that gap is intentional and about to be moot everywhere. ## Current state (as of this pass) -| Repo | bakery | pacman | notes | -|---|---|---|---| -| bread-ecosystem (bakery product) | yes | yes | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | -| bread-ecosystem (bread-theme product) | yes | no | | -| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | complete, used as templates | -| breadclip, breadmon, breadsearch, breadshot | yes | no | complete | -| breadlock, breadhelp | no | yes | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | -| bos-settings | yes | yes | was missing both the registry entry and `release.yml`; both added | -| bos | no | no | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | -| breadarr | no | no | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file | +| Repo | bakery | pacman | tracks | notes | +|---|---|---|---|---| +| bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | +| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | | +| bread | yes | yes | stable, beta, dev | pilot repo for the beta/dev track rollout | +| breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable only | complete, used as templates; not yet rolled out to beta/dev | +| breadclip, breadmon, breadsearch, breadshot | yes | no | stable only | complete | +| breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | +| bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added | +| bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | +| breadarr | no | no | n/a | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file | diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 2b7a4d8..05b0c20 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,19 +1,38 @@ #!/usr/bin/env bash -# Generate dl.breadway.dev/index.json from: -# - registry/bread-ecosystem.toml (product list) -# - //bakery.toml (per-product metadata, uploaded by release.yml) -# - / (built binaries + sha256 files) +# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see +# TRACK below) from: +# - registry/bread-ecosystem.toml (product list) +# - //bakery.toml (per-product metadata, uploaded by release.yml) +# - / (built binaries + sha256 files) # # Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout). # Run on hestia after each product build, before the dl server is refreshed. +# +# TRACK selects which build track to generate an index for: "stable" +# (default — reads/writes DL_DIR directly, byte-for-byte the same behavior +# as before tracks existed), "beta", or "dev" (both read/write a +# DL_DIR// subtree, so they never collide with stable's paths). A +# product with no release dir under the selected track's tree is skipped +# with a warning, same as an unreleased product is today — most products +# won't have a beta/dev build for a while after this lands. # Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum set -euo pipefail SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}" +TRACK="${TRACK:-stable}" GH_BASE="https://github.com" -OUT="${DL_DIR}/index.json" + +if [[ "${TRACK}" == "stable" ]]; then + PKG_ROOT="${DL_DIR}" + URL_ROOT="${DL_BASE}" + OUT="${DL_DIR}/index.json" +else + PKG_ROOT="${DL_DIR}/${TRACK}" + URL_ROOT="${DL_BASE}/${TRACK}" + OUT="${DL_DIR}/${TRACK}/index.json" +fi # Read the product list from the registry TOML instead of a hardcoded array. mapfile -t products < <(python3 -c " @@ -30,8 +49,8 @@ build_package_json() { local name="$1" local repo="$2" - # Find the latest version dir under DL_DIR// - local pkg_dir="${DL_DIR}/${name}" + # Find the latest version dir under PKG_ROOT// + local pkg_dir="${PKG_ROOT}/${name}" if [[ ! -d "${pkg_dir}" ]]; then echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 return 1 @@ -65,8 +84,17 @@ build_package_json() { if [[ -f "${sha256_path}" ]]; then sha256="$(awk '{print $1}' "${sha256_path}")" fi - local dl_url="${DL_BASE}/${name}/${version}/${bin_name}" - local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + local dl_url="${URL_ROOT}/${name}/${version}/${bin_name}" + # dev/beta builds never get a real GitHub Release (see the dev/beta + # CI workflows — that step is intentionally skipped for those + # tracks), so github_url just mirrors dl_url rather than pointing at + # a release asset that doesn't exist. + local gh_url + if [[ "${TRACK}" == "stable" ]]; then + gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + else + gh_url="${dl_url}" + fi local entry entry="$(jq -n \ @@ -86,7 +114,7 @@ build_package_json() { bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" fi if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 + echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2 return 1 fi