commit
3c51e7f561
12 changed files with 825 additions and 54 deletions
|
|
@ -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/<issue> 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 }}
|
||||
|
|
|
|||
|
|
@ -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/<issue> 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" \
|
||||
|
|
|
|||
21
CLAUDE.md
21
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`.
|
||||
|
|
|
|||
95
CONTRIBUTING.md
Normal file
95
CONTRIBUTING.md
Normal file
|
|
@ -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/<short-name>
|
||||
fix/<issue-number-or-short-name>
|
||||
```
|
||||
|
||||
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/<issue>` 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.<timestamp>+<sha>` /
|
||||
`-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.
|
||||
10
README.md
10
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/<pkg>/<version>/`, 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 <stable|beta|dev>`.
|
||||
|
||||
### Release artifact contract
|
||||
|
||||
Each product's `release.yml` **must** upload the following files alongside
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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/<name>` destination) so tests can extract into
|
||||
/// a tempdir instead.
|
||||
fn fetch_extract_archive(
|
||||
pkg: &Package,
|
||||
filename: &str,
|
||||
sha256: &Option<String>,
|
||||
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<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}")
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -107,6 +107,30 @@ pub struct Package {
|
|||
pub config: Option<ConfigScaffold>,
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// License artifact filename (e.g. "LICENSE"), installed to
|
||||
/// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
|
||||
/// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
|
||||
#[serde(default)]
|
||||
pub license_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license_file_sha256: Option<String>,
|
||||
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
||||
/// installed to `~/.local/share/applications/<name>.desktop` so the
|
||||
/// app shows up in any XDG-compliant launcher without root.
|
||||
#[serde(default)]
|
||||
pub desktop_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub desktop_file_sha256: Option<String>,
|
||||
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
||||
/// in the release dir, extracted to `~/.local/share/<name>/` 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<String>,
|
||||
#[serde(default)]
|
||||
pub data_archive_sha256: Option<String>,
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
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.<app_id>.**"` 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");
|
||||
|
|
|
|||
|
|
@ -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/<pkg>/<ver>/` | push tag `v*` |
|
||||
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `beta-v*` |
|
||||
| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl/<pkg>/<ver>/` | push tag `v*` on `main` |
|
||||
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push to branch `beta` |
|
||||
| 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.
|
||||
(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/<name>` or `fix/<issue>` 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/<issue>` 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.<timestamp>+<sha>` / `-beta.<timestamp>+<sha>` 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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}'
|
||||
}
|
||||
|
||||
|
|
|
|||
88
upgrade.md
Normal file
88
upgrade.md
Normal file
|
|
@ -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 <view>` 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue