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.
This commit is contained in:
parent
db2fa3c4b4
commit
4ac54c610d
15 changed files with 771 additions and 67 deletions
61
.forgejo/workflows/beta-bakery.yml
Normal file
61
.forgejo/workflows/beta-bakery.yml
Normal 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
|
||||
56
.forgejo/workflows/beta-bread-theme.yml
Normal file
56
.forgejo/workflows/beta-bread-theme.yml
Normal 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
|
||||
80
.forgejo/workflows/dev-bakery.yml
Normal file
80
.forgejo/workflows/dev-bakery.yml
Normal 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
|
||||
69
.forgejo/workflows/dev-bread-theme.yml
Normal file
69
.forgejo/workflows/dev-bread-theme.yml
Normal 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
1
Cargo.lock
generated
|
|
@ -112,6 +112,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"hex",
|
||||
"minisign-verify",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ hex = { workspace = true }
|
|||
clap = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
minisign-verify = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
" {}",
|
||||
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!(
|
||||
" {}",
|
||||
ui::fail(&format!(
|
||||
"{package_name}: missing system deps: {}",
|
||||
rep.missing.join(", ")
|
||||
))
|
||||
);
|
||||
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||
false
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
90
bakery/src/track.rs
Normal 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
60
bakery/src/ui.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Generate dl.breadway.dev/index.json from:
|
||||
# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see
|
||||
# TRACK below) 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)
|
||||
# - <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"
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue