bakery: add stable/beta/dev build tracks
All checks were successful
dev bread-theme / build (push) Successful in 13s
dev bakery / build (push) Successful in 1m2s

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.
This commit is contained in:
Breadway 2026-07-22 09:19:31 +08:00
parent db2fa3c4b4
commit 4ac54c610d
15 changed files with 771 additions and 67 deletions

View file

@ -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" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,56 @@
name: beta bread-theme
# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate
# promotion step, distinct from dev-bread-theme.yml's 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 bread-theme --bin bread-theme
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#beta-v}"
PKG_DIR="/srv/breadway-dl/beta/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/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" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,80 @@
name: dev bakery
# Publishes a dev-track build on every push to `dev` — separate from
# release-bakery.yml's tag-triggered stable releases. See docs/release-channels.md
# for the three-track policy (stable/beta/dev) this is part of.
on:
push:
branches: ['dev']
paths:
- 'bakery/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bakery.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --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
# Auto-bumps the patch version from Cargo.toml and appends a
# timestamp+sha dev suffix — no developer discipline required, and the
# result is visibly "ahead of" the last stable patch release while
# staying valid semver (comparable within the dev track by bakery's
# `is_newer`).
- name: compute dev version
run: |
set -euo pipefail
cd src
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$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" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here, unlike release-bakery.yml — dev
# builds happen on every push and would spam a release per commit, so
# dl.breadway.dev/dev/ is the only distribution point for this track.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,69 @@
name: dev bread-theme
# Publishes a dev-track build on every push to `dev` — separate from
# release-bread-theme.yml's tag-triggered stable releases. See
# docs/release-channels.md for the three-track policy this is part of.
on:
push:
branches: ['dev']
paths:
- 'bread-theme/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bread-theme.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme
- name: compute dev version
run: |
set -euo pipefail
cd src
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$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" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: cd src && bash scripts/gen-index.sh

1
Cargo.lock generated
View file

@ -112,6 +112,7 @@ dependencies = [
"dirs",
"hex",
"minisign-verify",
"semver",
"serde",
"serde_json",
"sha2",

View file

@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive", "env"] }
chrono = "0.4"
minisign-verify = "0.2"
tracing = "0.1"
semver = "1"
[profile.release]
lto = "thin"

View file

@ -19,6 +19,7 @@ hex = { workspace = true }
clap = { workspace = true }
chrono = { workspace = true }
minisign-verify = { workspace = true }
semver = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,3 +1,4 @@
use crate::ui;
use anyhow::Result;
use std::process::Command;
@ -52,28 +53,37 @@ fn pkg_config_exists(lib: &str) -> 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

View file

@ -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<String>,
},
/// 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<String> = 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<String> = 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"));
}
}

View file

@ -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<Index> {
let cache_path = cache_path();
pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
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<Index> {
}
}
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<Index> {
fn read_and_verify_cache(
cache_path: &PathBuf,
sig_cache_path: &PathBuf,
track: Track,
) -> Result<Index> {
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<Index> {
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<Index> {
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<String> {
.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()));
}
}

View file

@ -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<String, InstalledPackage>,
}
@ -52,6 +58,10 @@ impl State {
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
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();

90
bakery/src/track.rs Normal file
View file

@ -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<Self, Self::Err> {
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::<Track>().unwrap(), track);
}
}
#[test]
fn from_str_is_case_insensitive() {
assert_eq!("DEV".parse::<Track>().unwrap(), Track::Dev);
assert_eq!("Beta".parse::<Track>().unwrap(), Track::Beta);
}
#[test]
fn from_str_rejects_unknown() {
assert!("nightly".parse::<Track>().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);
}
}

60
bakery/src/ui.rs Normal file
View file

@ -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());
}
}

View file

@ -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/<pkg>/<ver>/` | push tag `v*` |
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `beta-v*` |
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | 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 <stable|beta|dev>`
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 |

View file

@ -1,19 +1,38 @@
#!/usr/bin/env bash
# Generate dl.breadway.dev/index.json from:
# - registry/bread-ecosystem.toml (product list)
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - <DL_DIR>/ (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)
# - <PKG_ROOT>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - <PKG_ROOT>/ (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/<track>/ 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/<name>/
local pkg_dir="${DL_DIR}/${name}"
# Find the latest version dir under PKG_ROOT/<name>/
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