diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml index 05c1774..c20c069 100644 --- a/.forgejo/workflows/beta-bakery.yml +++ b/.forgejo/workflows/beta-bakery.yml @@ -1,11 +1,17 @@ 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. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See docs/release-channels.md for the three-track policy (stable/beta/dev). on: push: - tags: ['beta-v*'] + branches: ['beta'] + paths: + - 'bakery/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bakery.yml' jobs: build: @@ -15,7 +21,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -24,10 +30,36 @@ jobs: - name: test run: cd src && cargo test --release --locked -p bakery + # Auto-bumps the patch version from the latest tag and appends a + # timestamp+sha beta suffix — no developer discipline required, and the + # result is visibly "ahead of" the last stable patch release while + # staying valid semver (comparable within the beta track by bakery's + # `is_newer`). + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - 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" @@ -42,7 +74,6 @@ jobs: 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" \ @@ -52,8 +83,9 @@ jobs: 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/. + # No GitHub Release upload step here, unlike release-bakery.yml — beta + # builds happen on every push while the branch is frozen for testing, + # so dl.breadway.dev/beta/ is the only distribution point for this track. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} diff --git a/.forgejo/workflows/beta-bread-theme.yml b/.forgejo/workflows/beta-bread-theme.yml index 24bde62..c1521b9 100644 --- a/.forgejo/workflows/beta-bread-theme.yml +++ b/.forgejo/workflows/beta-bread-theme.yml @@ -1,11 +1,17 @@ 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. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See docs/release-channels.md for the three-track policy this is part of. on: push: - tags: ['beta-v*'] + branches: ['beta'] + paths: + - 'bread-theme/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bread-theme.yml' jobs: build: @@ -15,16 +21,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --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 beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - 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" @@ -39,7 +66,6 @@ jobs: 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" \ diff --git a/CLAUDE.md b/CLAUDE.md index 261735d..17810c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,26 @@ -# CLAUDE.md — Repo hygiene (local only, not committed) +# CLAUDE.md — Repo hygiene Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. -## Branch model -- `main` — release branch, always tag-ready. Don't commit directly to it. -- `dev` — integration branch. Land day-to-day work here first. -- Feature/fix work goes on short-lived branches off `dev` (`feature/x`, `fix/x`), merged back into `dev`, then `dev` → `main` when ready to release. +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +dev/beta/main lifecycle, `feature/x`/`fix/x` branch naming, when to cut or +reset `beta`, etc). Don't improvise a different workflow. The short version: +`main` is tag-ready and only moves via a `beta` merge; `dev` and `beta` both +auto-publish a build on every push (dev-track / beta-track respectively); +`beta` is a frozen stabilization branch cut from `dev` roughly weekly and +promoted to `main` roughly monthly. `git branch -f beta dev` (plain +branch-pointer move) is how `beta` gets reset — never `git checkout +main`/`git merge` for this. ## Remotes - `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. - `github` — GitHub mirror. Push both when publishing. ## CI -- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev` or `main` runs nothing. Tag a release to trigger packaging. -- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging to `dev`/`main`. +- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev`, `beta`, or `main` doesn't run these. Tag a release to trigger packaging. +- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['dev']`; `beta-bakery.yml` / `beta-bread-theme.yml` trigger on `push: branches: ['beta']` — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full three-track (stable/beta/dev) policy. +- No build/lint/test CI runs on ordinary commits or PRs to `dev`/`beta` beyond what those track workflows do — there's no separate lint/PR-check pipeline. ## Cleanup - Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6087898 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing + +This repo hosts `bakery` (the ecosystem package manager) and `bread-theme` +(the shared theming crate). Other ecosystem products (`bread`, `breadbar`, +`breadbox`, …) live in their own repos under `Breadway/` but follow the same +workflow described here. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +cargo build --release -p bakery +cargo test --release -p bakery +``` + +Both `bakery` and `bread-theme` are members of this workspace's Cargo.toml. +Run the same commands with `-p bread-theme --bin bread-theme` for that crate. + +## CI + +- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `dev`. +- `beta-bakery.yml` / `beta-bread-theme.yml` — triggered on push to `beta`. +- `release-bakery.yml` / `release-bread-theme.yml` — triggered on a `v*` tag + push, cuts the actual stable release. +- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[`docs/release-channels.md`](docs/release-channels.md) for the full policy, +including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. diff --git a/README.md b/README.md index fff6340..22c8ceb 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ bread-ecosystem/ ## Release pipeline -Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a -`.github/workflows/release.yml` that triggers on `v*` tags. The workflow +Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has +`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow runs on a self-hosted runner on hestia, builds a stripped x86_64 binary, deposits it at `dl.breadway.dev///`, updates `index.json`, and mirrors the binary to GitHub Releases as a fallback. @@ -132,6 +132,12 @@ and mirrors the binary to GitHub Releases as a fallback. `bakery` always tries `dl.breadway.dev` first and transparently falls back to the GitHub Release URL recorded in the manifest. +Beyond stable releases, most products also publish **dev** and **beta** +tracks — continuous builds off the `dev` and `beta` branches, respectively. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the branch/release workflow and +[`docs/release-channels.md`](docs/release-channels.md) for the full track +policy. Switch tracks with `bakery track set `. + ### Release artifact contract Each product's `release.yml` **must** upload the following files alongside diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 3b4c925..e6ab26e 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -23,19 +23,34 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { scaffold_config(cfg, pkg)?; } - // 3. Install systemd user units. + // 3. Install license file, if declared. + if let Some(license) = &pkg.license_file { + install_license(pkg, license)?; + } + + // 4. Install desktop entry, if declared. + if let Some(desktop) = &pkg.desktop_file { + install_desktop_file(pkg, desktop)?; + } + + // 5. Download + extract data archive, if declared. + if let Some(archive) = &pkg.data_archive { + install_data_archive(pkg, archive)?; + } + + // 6. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { install_service(svc, bin_dir, pkg)?; service_names.push(svc.unit.clone()); } - // 4. Run post_install hooks. + // 7. Run post_install hooks. for cmd in &pkg.post_install { run_hook(cmd, &pkg.name)?; } - // 5. Record in state. + // 8. Record in state. let mut state = State::load()?; state.record(InstalledPackage { name: pkg.name.clone(), @@ -158,6 +173,110 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) } +/// Download `filename` from `pkg`'s release dir, verify it against `sha256` +/// (refusing an unverified download the same way `scaffold_config` does), +/// and write it to `dest`. Shared by `install_license`/`install_desktop_file` +/// since both are "fetch one small artifact, verify, place" — unlike a config +/// example, these aren't user-editable, so they're always refreshed rather +/// than skipped when already present. +fn fetch_verify_write( + pkg: &Package, + filename: &str, + sha256: &Option, + dest: &Path, + label: &str, +) -> Result<()> { + let Some((primary, fallback)) = pkg.artifact_urls(filename) else { + eprintln!(" warning: no artifact URL to download {label} ({filename})"); + return Ok(()); + }; + let bytes = match fetch_binary(&primary, &fallback) { + Ok(b) => b, + Err(e) => { + eprintln!(" warning: could not download {label} {filename}: {e}"); + return Ok(()); + } + }; + let Some(expected) = sha256 else { + eprintln!( + " warning: index.json has no sha256 for {label} {filename} — \ + refusing to install an unverified download" + ); + return Ok(()); + }; + if let Err(e) = verify_sha256(&bytes, expected) { + eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed"); + return Ok(()); + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; + println!(" installed {label} at {}", dest.display()); + Ok(()) +} + +fn install_license(pkg: &Package, filename: &str) -> Result<()> { + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("licenses") + .join(&pkg.name) + .join("LICENSE"); + fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license") +} + +fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("applications") + .join(format!("{}.desktop", pkg.name)); + fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry") +} + +fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join(&pkg.name); + fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) +} + +/// Downloads + verifies a `.tar.gz` artifact, then extracts it into +/// `dest_dir`. Shells out to `tar` rather than adding an archive-extraction +/// crate dependency — `tar` is universally present on Linux and this file +/// already shells out to `systemctl` for the same "trust the base system +/// has this" reason. Split from `install_data_archive` (which just supplies +/// the real `~/.local/share/` destination) so tests can extract into +/// a tempdir instead. +fn fetch_extract_archive( + pkg: &Package, + filename: &str, + sha256: &Option, + dest_dir: &Path, +) -> Result<()> { + let tmp_archive = std::env::temp_dir().join(format!("bakery-{}-{filename}", pkg.name)); + + fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; + if !tmp_archive.exists() { + // fetch_verify_write already warned (download/checksum failure). + return Ok(()); + } + + std::fs::create_dir_all(dest_dir)?; + let status = Command::new("tar") + .args(["xzf", &tmp_archive.to_string_lossy(), "-C"]) + .arg(dest_dir) + .status() + .with_context(|| format!("running tar to extract {filename}"))?; + let _ = std::fs::remove_file(&tmp_archive); + + if status.success() { + println!(" extracted {filename} to {}", dest_dir.display()); + } else { + eprintln!(" warning: tar exited with {status} extracting {filename}"); + } + Ok(()) +} + fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { let service_dir = systemd_user_dir(); std::fs::create_dir_all(&service_dir)?; @@ -343,9 +462,188 @@ fn warn_path_if_needed(bin_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use crate::manifest::{Binary, Package}; + use sha2::Digest; use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; use tempfile::tempdir; + /// Serves `body` for exactly one HTTP/1.0 request on an ephemeral local + /// port, then stops. Real network I/O over loopback — exercises + /// `fetch_verify_write`'s actual `fetch_binary` call, not just its + /// surrounding logic, without any new test dependency. + fn serve_once(body: &'static [u8]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body); + } + }); + format!("http://{addr}") + } + + /// Same as `serve_once` but for a runtime-owned body (e.g. a tar.gz + /// built into a tempdir during the test), which can't satisfy `'static`. + fn serve_once_owned(body: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(&body); + } + }); + format!("http://{addr}") + } + + fn test_package(binary_url: &str) -> Package { + Package { + name: "breadhelp".to_string(), + description: "test".to_string(), + version: "1.0.0".to_string(), + binaries: vec![Binary { + name: "breadhelp-x86_64".to_string(), + dl_url: format!("{binary_url}/breadhelp-x86_64"), + github_url: format!("{binary_url}/breadhelp-x86_64"), + sha256: String::new(), + }], + system_deps: vec![], + optional_system_deps: vec![], + bread_deps: vec![], + services: vec![], + config: None, + post_install: vec![], + license_file: None, + license_file_sha256: None, + desktop_file: None, + desktop_file_sha256: None, + data_archive: None, + data_archive_sha256: None, + } + } + + #[test] + fn install_license_writes_verified_file() { + let license_bytes = b"MIT License\n"; + let sha256 = sha2::Sha256::digest(license_bytes); + let sha256_hex = hex::encode(sha256); + + let base_url = serve_once(license_bytes); + let mut pkg = test_package(&base_url); + pkg.license_file_sha256 = Some(sha256_hex); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") + .unwrap(); + + assert_eq!(fs::read(&dest).unwrap(), license_bytes); + } + + #[test] + fn install_desktop_file_writes_verified_file() { + let desktop_bytes = b"[Desktop Entry]\nName=BreadHelp\n"; + let sha256 = sha2::Sha256::digest(desktop_bytes); + let sha256_hex = hex::encode(sha256); + + let base_url = serve_once(desktop_bytes); + let mut pkg = test_package(&base_url); + pkg.desktop_file_sha256 = Some(sha256_hex); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("breadhelp.desktop"); + fetch_verify_write( + &pkg, + "breadhelp.desktop", + &pkg.desktop_file_sha256.clone(), + &dest, + "desktop entry", + ) + .unwrap(); + + assert_eq!(fs::read(&dest).unwrap(), desktop_bytes); + } + + #[test] + fn fetch_verify_write_refuses_checksum_mismatch() { + let bytes = b"tampered content"; + let base_url = serve_once(bytes); + let mut pkg = test_package(&base_url); + pkg.license_file_sha256 = Some("0".repeat(64)); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") + .unwrap(); + + // Refused, not erred (matches scaffold_config's warn-and-continue + // posture) — the file must not have been written. + assert!(!dest.exists()); + } + + #[test] + fn fetch_verify_write_refuses_missing_sha256() { + let bytes = b"some content"; + let base_url = serve_once(bytes); + let pkg = test_package(&base_url); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &None, &dest, "license").unwrap(); + + assert!(!dest.exists()); + } + + #[test] + fn fetch_extract_archive_extracts_tar_gz_contents() { + // Build a real tar.gz fixture via the actual `tar` binary — matches + // exactly what CI produces, rather than hand-rolling gzip framing. + let src = tempdir().unwrap(); + fs::create_dir_all(src.path().join("content/tours")).unwrap(); + fs::write( + src.path().join("content/tours/onboarding.toml"), + b"[[step]]\n", + ) + .unwrap(); + let archive_path = src.path().join("content.tar.gz"); + let status = Command::new("tar") + .args(["czf"]) + .arg(&archive_path) + .args(["-C"]) + .arg(src.path()) + .arg("content") + .status() + .unwrap(); + assert!(status.success()); + let archive_bytes = fs::read(&archive_path).unwrap(); + + let sha256_hex = hex::encode(sha2::Sha256::digest(&archive_bytes)); + let base_url = serve_once_owned(archive_bytes); + let pkg = test_package(&base_url); + + let dest_dir = tempdir().unwrap(); + fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()) + .unwrap(); + + let extracted = dest_dir.path().join("content/tours/onboarding.toml"); + assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); + } + #[test] fn strip_known_suffixes() { assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd"); diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 248b715..c8541d5 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -107,6 +107,30 @@ pub struct Package { pub config: Option, #[serde(default)] pub post_install: Vec, + /// License artifact filename (e.g. "LICENSE"), installed to + /// `~/.local/share/licenses//LICENSE` — the bakery equivalent of + /// what a PKGBUILD's `package()` does with `/usr/share/licenses`. + #[serde(default)] + pub license_file: Option, + #[serde(default)] + pub license_file_sha256: Option, + /// Desktop entry artifact filename (e.g. "breadhelp.desktop"), + /// installed to `~/.local/share/applications/.desktop` so the + /// app shows up in any XDG-compliant launcher without root. + #[serde(default)] + pub desktop_file: Option, + #[serde(default)] + pub desktop_file_sha256: Option, + /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` + /// in the release dir, extracted to `~/.local/share//` on + /// install. For arbitrary data a package needs at runtime beyond a + /// config example (e.g. breadhelp's guide content), where a single + /// downloadable file + `tar` extraction is simpler than teaching + /// bakery to mirror a whole directory tree file-by-file. + #[serde(default)] + pub data_archive: Option, + #[serde(default)] + pub data_archive_sha256: Option, } impl Package { @@ -350,4 +374,41 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); } + + fn minimal_package_json() -> &'static str { + r#"{ + "name": "breadhelp", + "description": "test", + "version": "1.0.0", + "binaries": [], + "config": null + }"# + } + + #[test] + fn license_and_desktop_fields_default_to_none_on_old_shape_json() { + // Simulates an index.json produced before license_file/desktop_file + // existed — must not fail to parse. + let pkg: Package = serde_json::from_str(minimal_package_json()).unwrap(); + assert!(pkg.license_file.is_none()); + assert!(pkg.license_file_sha256.is_none()); + assert!(pkg.desktop_file.is_none()); + assert!(pkg.desktop_file_sha256.is_none()); + } + + #[test] + fn license_and_desktop_fields_roundtrip() { + let mut pkg: Package = serde_json::from_str(minimal_package_json()).unwrap(); + pkg.license_file = Some("LICENSE".to_string()); + pkg.license_file_sha256 = Some("abc123".to_string()); + pkg.desktop_file = Some("breadhelp.desktop".to_string()); + pkg.desktop_file_sha256 = Some("def456".to_string()); + + let json = serde_json::to_string(&pkg).unwrap(); + let restored: Package = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.license_file.as_deref(), Some("LICENSE")); + assert_eq!(restored.license_file_sha256.as_deref(), Some("abc123")); + assert_eq!(restored.desktop_file.as_deref(), Some("breadhelp.desktop")); + assert_eq!(restored.desktop_file_sha256.as_deref(), Some("def456")); + } } diff --git a/bread-utils/src/bread_client.rs b/bread-utils/src/bread_client.rs index 3f4adcf..9976fc6 100644 --- a/bread-utils/src/bread_client.rs +++ b/bread-utils/src/bread_client.rs @@ -115,6 +115,50 @@ impl BreadClient { let _ = writeln!(stream, "{line}"); } + /// Send a one-shot IPC request and return its `result`, or `None` on any + /// failure — breadd unreachable, a malformed response, or an `error` + /// field in the response. Mirrors `emit`'s graceful-degradation stance: + /// a caller checks for `None` the same way it'd handle "daemon not + /// installed," not via a `Result` that forces error-path plumbing for + /// what is, for most callers (a refresh-on-connect read), an expected + /// possibility rather than an exceptional one. + /// + /// Unlike `emit`, this is not restricted to the client's own namespace — + /// `method`/`params` map directly onto breadd's IPC method table (see + /// `Documentation.md`'s "Dictionary: IPC protocol"), most of which + /// (`state.get`, `widgets.list`, ...) are cross-namespace reads by + /// design. Only first-party compiled code links `bread-utils`, so this + /// carries the same trust level as `emit`'s own request construction. + pub fn request(&self, method: &str, params: Value) -> Option { + let request = json!({ + "id": "0", + "method": method, + "params": params, + }); + let line = serde_json::to_string(&request).ok()?; + + let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?; + stream + .set_write_timeout(Some(Duration::from_millis(200))) + .ok()?; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + writeln!(stream, "{line}").ok()?; + + let mut response_line = String::new(); + BufReader::new(stream).read_line(&mut response_line).ok()?; + if response_line.trim().is_empty() { + return None; + } + + let value: Value = serde_json::from_str(&response_line).ok()?; + if value.get("error").is_some() { + return None; + } + value.get("result").cloned() + } + /// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking /// `on_event` for each one on a dedicated background thread. Typically /// called with `"bread.command..**"` to receive commands @@ -290,6 +334,14 @@ mod tests { // integration tests for the IPC-side of namespace validation. } + #[test] + fn request_returns_none_when_daemon_is_unreachable() { + // No daemon present in the test environment; must return None + // promptly rather than blocking or panicking. + let client = BreadClient::connect("clip"); + assert!(client.request("widgets.list", json!(null)).is_none()); + } + #[test] fn subscription_stop_joins_the_background_thread() { let client = BreadClient::connect("clip"); diff --git a/docs/release-channels.md b/docs/release-channels.md index d717ff4..019ecf3 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -54,31 +54,54 @@ should never carry a `bakery.toml` or `PKGBUILD`. 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. +`beta` (a frozen stabilization branch), 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*` | +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` on `main` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push to branch `beta` | | 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. +(no release-per-commit spam, and beta doesn't need a GitHub mirror either) — +`dl.breadway.dev` is their only distribution point. + +**The full branch lifecycle** (see also `CLAUDE.md`'s Branch model section): +day-to-day work lands on `feature/` or `fix/` branches, merged +into `dev`. `dev` publishes a fresh dev-track build on every push — this is +the "test for a while, fix forward with another push" loop. When `dev` has +gone roughly a week without new issues, cut `beta` fresh from `dev`'s current +tip (`git branch -f beta dev` from a clean checkout, then force-push) — this +freezes it as the stabilization target. `beta` publishes on every push the +same way `dev` does, but only `fix/` branches merged directly into +`beta` should land there afterward; `dev` keeps moving independently for the +next cycle. After roughly a month of `beta` going without new issues, merge +`beta` into `main` and push a `vX.Y.Z` tag from `main` to cut the actual +stable release (the merge itself triggers nothing — tag-push is what fires +`release.yml`). Reset `beta` fresh from `dev` again to start the next cycle. + +Auto-versioning: both `dev` and `beta` compute their build version from the +latest published `vX.Y.Z` tag (via `git ls-remote --tags`, not `Cargo.toml` — +`Cargo.toml` can drift stale relative to the actual last release) plus a +`-dev.+` / `-beta.+` suffix. This is +self-healing regardless of `Cargo.toml` drift and keeps `bakery`'s semver +check (`is_newer`) meaningful — it will correctly refuse to "update" to a +build that isn't actually newer than what's installed. 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 — +`release.yml`. Also create the repo's `dev` and `beta` branches if they don't +exist yet (`git checkout -b dev main` / `git checkout -b beta dev`, push +both). 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. @@ -124,9 +147,8 @@ missing it; that gap is intentional and about to be moot everywhere. |---|---|---|---|---| | 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 | +| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable, beta, dev | complete on all three tracks | +| breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | complete on all three tracks | | 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 | diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 4620361..b6d0287 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -71,3 +71,8 @@ description = "Screenshot utility for the bread ecosystem" name = "bos-settings" repo = "Breadway/bos-settings" description = "System settings app for Bread OS" + +[[products]] +name = "breadhelp" +repo = "Breadway/breadhelp" +description = "Onboarding and help center for Bread OS" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 05b0c20..29866df 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -67,6 +67,41 @@ build_package_json() { local version version="$(basename "${version_dir}")" + # Locate bakery.toml. The release workflow copies it into the version dir + # alongside the binaries (${version_dir}/bakery.toml). Fall back to a + # sibling repo checkout for local dev use. Done before the binaries loop + # below so license_file/desktop_file (if declared) can be excluded from + # it by name — otherwise they'd get swept up as "binaries" with no + # checksum, the same gotcha this loop's other exclusions guard against. + local bakery_toml="${version_dir}/bakery.toml" + if [[ ! -f "${bakery_toml}" ]]; then + bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" + fi + if [[ ! -f "${bakery_toml}" ]]; then + 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 + + local license_file_name desktop_file_name data_archive_name + license_file_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('license_file', '')) +" 2>/dev/null || true)" + desktop_file_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('desktop_file', '')) +" 2>/dev/null || true)" + data_archive_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('data_archive', '')) +" 2>/dev/null || true)" + # Collect all binaries in the version dir (executables only; skip metadata files). local binaries_json="[]" for bin_path in "${version_dir}"/*; do @@ -76,6 +111,9 @@ build_package_json() { [[ "${bin_path}" == *.css ]] && continue [[ "${bin_path}" == *.txt ]] && continue [[ "${bin_path}" == *.minisig ]] && continue + [[ -n "${license_file_name}" && "${bin_path}" == "${version_dir}/${license_file_name}" ]] && continue + [[ -n "${desktop_file_name}" && "${bin_path}" == "${version_dir}/${desktop_file_name}" ]] && continue + [[ -n "${data_archive_name}" && "${bin_path}" == "${version_dir}/${data_archive_name}" ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name bin_name="$(basename "${bin_path}")" @@ -106,18 +144,6 @@ build_package_json() { binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" done - # Locate bakery.toml. The release workflow copies it into the version dir - # alongside the binaries (${version_dir}/bakery.toml). Fall back to a - # sibling repo checkout for local dev use. - local bakery_toml="${version_dir}/bakery.toml" - if [[ ! -f "${bakery_toml}" ]]; then - bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" - fi - if [[ ! -f "${bakery_toml}" ]]; then - 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 - local description system_deps optional_system_deps bread_deps services config post_install description="$(python3 -c " @@ -213,6 +239,47 @@ with open('${bakery_toml}', 'rb') as f: print(json.dumps(d.get('install', {}).get('post_install', []))) " 2>/dev/null || echo "[]")" + # license_file / desktop_file: plain filename fields in bakery.toml + # (names already read above, before the binaries loop), same "artifact + # in the version dir, sha256 computed here" pattern as config.example. + # Empty string (not null) when unset, matching how the rest of this + # script signals "field absent" to jq below. + license_file="${license_file_name}" + license_file_sha256="" + if [[ -n "${license_file}" ]]; then + license_path="${version_dir}/${license_file}" + if [[ -f "${license_path}" ]]; then + license_file_sha256="$(sha256sum "${license_path}" | awk '{print $1}')" + else + echo " warning: license_file '${license_file}' not found at ${license_path}" >&2 + license_file="" + fi + fi + + desktop_file="${desktop_file_name}" + desktop_file_sha256="" + if [[ -n "${desktop_file}" ]]; then + desktop_path="${version_dir}/${desktop_file}" + if [[ -f "${desktop_path}" ]]; then + desktop_file_sha256="$(sha256sum "${desktop_path}" | awk '{print $1}')" + else + echo " warning: desktop_file '${desktop_file}' not found at ${desktop_path}" >&2 + desktop_file="" + fi + fi + + data_archive="${data_archive_name}" + data_archive_sha256="" + if [[ -n "${data_archive}" ]]; then + data_archive_path="${version_dir}/${data_archive}" + if [[ -f "${data_archive_path}" ]]; then + data_archive_sha256="$(sha256sum "${data_archive_path}" | awk '{print $1}')" + else + echo " warning: data_archive '${data_archive}' not found at ${data_archive_path}" >&2 + data_archive="" + fi + fi + jq -n \ --arg name "${name}" \ --arg description "${description}" \ @@ -224,6 +291,12 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) --argjson services "${services}" \ --argjson config "${config}" \ --argjson post_install "${post_install}" \ + --arg license_file "${license_file}" \ + --arg license_file_sha256 "${license_file_sha256}" \ + --arg desktop_file "${desktop_file}" \ + --arg desktop_file_sha256 "${desktop_file_sha256}" \ + --arg data_archive "${data_archive}" \ + --arg data_archive_sha256 "${data_archive_sha256}" \ '{ name: $name, description: $description, @@ -234,7 +307,13 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) bread_deps: $bread_deps, services: $services, config: $config, - post_install: $post_install + post_install: $post_install, + license_file: (if $license_file == "" then null else $license_file end), + license_file_sha256: (if $license_file_sha256 == "" then null else $license_file_sha256 end), + desktop_file: (if $desktop_file == "" then null else $desktop_file end), + desktop_file_sha256: (if $desktop_file_sha256 == "" then null else $desktop_file_sha256 end), + data_archive: (if $data_archive == "" then null else $data_archive end), + data_archive_sha256: (if $data_archive_sha256 == "" then null else $data_archive_sha256 end) }' } diff --git a/upgrade.md b/upgrade.md new file mode 100644 index 0000000..15b1824 --- /dev/null +++ b/upgrade.md @@ -0,0 +1,88 @@ +**Project Overview: Bread Screenshot System** + +### Goal +Add a maintainable, automated system to generate high-quality screenshots/renders of **all major UI views** across the Bread ecosystem. This will dramatically speed up UI development, visual regression testing, documentation, and marketing. + +### Scope + +**In Scope:** +- Automated screenshot generation for all major GUI components +- Support for different themes (pywal accents, light/dark if added later) +- Consistent naming and output structure +- Easy-to-run command (`bread capture --all` or similar) +- Integration with development workflow and CI (optional) + +**Out of Scope (Phase 1):** +- Video/GIF capture +- Full automated visual diffing (can be Phase 2) + +### Target Apps / Views + +1. **breadbar** + - Main bar (all placements) + - Control panel (full + sections) + - WiFi popover, media popover, etc. + - Notifications + +2. **breadman** + - All sidebar views (All, Upcoming, Todo, Reminder, etc.) + - Note cards in different states + - Editor / create flow + +3. **breadbox** + - Main launcher view + - Different contexts + +4. **bos-settings** + - All major panels + +5. **breadpad** (capture popup) + +6. **breadlock** (lock screen states) + +7. **Widgets** (test module that renders many widget examples) + +### Technical Approach (Most Idiomatic) + +**Core Components:** + +1. **Shared Library** (`bread-screenshots` crate in bread-ecosystem) + - Common screenshot utilities + - Window finding / targeting logic (using `gtk` or `grim`) + - Theme forcing + +2. **Per-App Screenshot Mode** + - Add `--screenshot ` flag to each GTK app + - Special runtime mode that opens the desired view and calls capture after render + +3. **Orchestrator** + - A small Rust binary (`bread-capture`) or bash + Rust hybrid + - Launches each app with proper flags, waits, captures, saves + +4. **Output Structure** + ``` + screenshots/ + ├── v0.8.0/ + │ ├── breadbar-main.png + │ ├── breadbar-control.png + │ ├── breadman-all.png + │ ├── breadman-todo.png + │ └── ... + └── latest/ (symlinks) + ``` + +### Recommended Implementation Steps + +1. Create `bread-screenshots` crate in bread-ecosystem +2. Add screenshot support to the most important apps first (breadman + breadbar) +3. Build the orchestrator tool +4. Add `bread capture` subcommand to the CLI +5. Document usage + add to CONTRIBUTING.md + +### Benefits + +- Much faster UI iteration +- Visual regression testing +- Always up-to-date marketing/docs screenshots +- Easier contributor onboarding for UI work +- Professional polish for the project