diff --git a/sethlabels-docs/plans/2026-04-29-packaging-implementation.md b/sethlabels-docs/plans/2026-04-29-packaging-implementation.md new file mode 100644 index 0000000..5f29ddb --- /dev/null +++ b/sethlabels-docs/plans/2026-04-29-packaging-implementation.md @@ -0,0 +1,1815 @@ +# sethLabels Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the deployment-fork packaging pipeline defined by `sethlabels-docs/specs/2026-04-29-packaging-design.md` — produce installable `.deb` + AppImage artifacts on Debian-family Linux and a Homebrew tap formula for macOS, while honoring strict-zero-source-patches (Invariant I1). + +**Architecture:** All sethLabels content lives in NEW top-level dirs (`scripts/`, `packaging/`, `sethlabels-docs/`, `tests-impl/`) and one new repo-root file (`README.sethlabels.md`). The Homebrew tap lives in a separate Gitea repo (`homebrew-tap`). Pure-logic scripts (`compute-version.sh`, `check-no-upstream-edits.sh`, `deps-debian.sh`) are TDD'd with `bats-core`. Build scripts (`build-deb.sh`, `build-appimages.sh`) are validated by inline smoke tests (T1–T4 from spec §10) against the artifacts they produce. + +**Tech Stack:** Bash (with `set -euo pipefail`), CMake/CPack (already present upstream), `linuxdeploy` + `linuxdeploy-plugin-qt` (for AppImage bundling), `bats-core` (shell test framework, `apt install bats`), Homebrew Formula DSL (Ruby). + +**Spec discrepancies to fix during implementation:** + +1. **Spec §5.2 omits `-D CPACK_PACKAGE_NAME=glabels-qt`.** Upstream `CMakeLists.txt:86` sets `CPACK_PACKAGE_NAME=glabels` — without the override the `.deb` would be named `glabels_${VERSION}_amd64.deb`, contradicting decision D6. Task 7 adds the override. +2. **Spec §F9 requires pinned linuxdeploy versions, no specific tag named.** Task 5 has the implementer query GitHub for the latest release tag of `linuxdeploy/linuxdeploy` and `linuxdeploy/linuxdeploy-plugin-qt`, then hardcode those tags in `scripts/lib/linuxdeploy.sh`. +3. **Spec §2 calls `.gitignore` "a one-time scaffold-time touch", but §5.5's allowlist permits ongoing edits.** Task 1 adds new entries (`build/`, `scripts/.cache/`, `*.AppImage`); the guardrail allows this since `.gitignore` is in the allowlist. + +**Plan-execution context:** This plan is best executed in a worktree via `superpowers:using-git-worktrees`. The plan does not create the worktree itself — the executor sets that up before starting Task 1. + +--- + +### File Structure + +| Path | Responsibility | Created in | +|------|----------------|------------| +| `scripts/compute-version.sh` | Emit `-seth` to stdout. Pure logic. | Task 2 | +| `scripts/check-no-upstream-edits.sh` | Guardrail enforcing I1; exits 1 on any non-allowlisted upstream-file edit. | Task 3 | +| `scripts/lib/deps-debian.sh` | Single source of truth for build deps; sourceable + executable. | Task 4 | +| `scripts/lib/linuxdeploy.sh` | Bootstrap + cache `linuxdeploy` and `linuxdeploy-plugin-qt`. | Task 5 | +| `packaging/deb-metadata.env` | Maintainer + section + homepage for CPack DEB. | Task 6 | +| `packaging/appimage-recipe.env` | linuxdeploy plugin allowlist + exclude list. | Task 6 | +| `packaging/changelog.md` | Human-readable per-release notes. | Task 6 | +| `scripts/build-deb.sh` | Driver: deps → guardrail → version → cmake → cpack → smoke (T1, T2). | Task 7 | +| `scripts/build-appimages.sh` | Driver: deps → guardrail → version → cmake → linuxdeploy x2 → smoke (T3, T4). | Task 8 | +| `scripts/README.md` | Operator-facing run instructions. | Task 9 | +| `README.sethlabels.md` | Repo-root entry point: fork purpose, install methods, build path, link to spec. | Task 10 | +| `tests-impl/test-compute-version.bats` | Bats tests for `compute-version.sh`. | Task 2 | +| `tests-impl/test-check-no-upstream-edits.bats` | Bats tests for `check-no-upstream-edits.sh`. | Task 3 | +| `tests-impl/test-deps-debian.bats` | Bats tests for `deps-debian.sh`. | Task 4 | +| `tests-impl/run-all.sh` | One-shot runner: `bats tests-impl/*.bats`. | Task 1 | +| `~/bin/homebrew-tap/Formula/glabels-qt.rb` | Brew formula (separate repo). | Task 11 | +| `~/bin/homebrew-tap/README.md` | Tap install instructions. | Task 11 | + +**Why `tests-impl/` and not `tests/`?** Upstream has `test-data/` at root but no `tests/`. Naming our test dir `tests-impl/` (impl = implementation tests, sethLabels-namespaced) avoids any visual collision and keeps the strict-zero boundary unmistakable. + +--- + +## Task 1: Directory skeleton + .gitignore additions + +**Files:** +- Modify: `.gitignore` (append three new patterns inside the existing sethLabels section) +- Create: `scripts/`, `scripts/lib/`, `packaging/`, `tests-impl/` (empty for now; populated by later tasks) +- Create: `tests-impl/run-all.sh` + +- [ ] **Step 1: Verify clean working tree before starting** + +```bash +git status +``` + +Expected: `nothing to commit, working tree clean` and the current branch is `main` (or your worktree branch). If dirty, stop and resolve before proceeding. + +- [ ] **Step 2: Create the directory skeleton** + +```bash +mkdir -p scripts/lib packaging tests-impl +``` + +- [ ] **Step 3: Append build-artifact patterns to `.gitignore`** + +Open `.gitignore` and **append** to the existing `# === sethLabels (deployment fork) additions ===` section (do NOT modify upstream entries above it): + +``` +# Build outputs (out-of-tree) +build/ + +# linuxdeploy + plugin caches (downloaded by scripts/lib/linuxdeploy.sh on first AppImage build) +scripts/.cache/ + +# Locally-produced AppImage artifacts +*.AppImage +``` + +- [ ] **Step 4: Verify the strict-zero allowlist still covers .gitignore** + +```bash +grep '\\.gitignore' sethlabels-docs/specs/2026-04-29-packaging-design.md +``` + +Expected: matches in §5.5 `allowed_pattern` and the §2 invariants table — `.gitignore` is allowlisted, so this edit does not violate I1. + +- [ ] **Step 5: Create `tests-impl/run-all.sh`** + +```bash +cat > tests-impl/run-all.sh <<'EOF' +#!/usr/bin/env bash +# Run the full bats test suite for sethLabels packaging scripts. +# Requires: bats (apt install bats). +set -euo pipefail +cd "$(dirname "$0")" +exec bats *.bats +EOF +chmod +x tests-impl/run-all.sh +``` + +- [ ] **Step 6: Verify the new dirs are tracked-empty (or .keep'd)** + +Empty dirs aren't tracked by git. We want the dirs visible after `git add`, so create a placeholder where needed and tracked-empty elsewhere is fine since later tasks populate them. + +```bash +ls -la scripts/ scripts/lib/ packaging/ tests-impl/ +``` + +Expected: each dir exists; `tests-impl/run-all.sh` is present and executable. + +- [ ] **Step 7: Commit** + +```bash +git add .gitignore scripts/ packaging/ tests-impl/ +git commit -m "chore: add packaging directory skeleton + .gitignore build patterns" +``` + +(`scripts/` and `packaging/` will commit only if non-empty — if not, that's fine; later tasks add files and push them.) + +--- + +## Task 2: `scripts/compute-version.sh` (TDD) + +**Files:** +- Test: `tests-impl/test-compute-version.bats` +- Create: `scripts/compute-version.sh` + +**What it does:** Emits `-seth` to stdout, where `` = `git describe --tags --abbrev=0 upstream/master` and `` = count of existing `-seth*` tags + 1. Pure logic, idempotent under serial single-author releases (spec §5.4). + +- [ ] **Step 1: Confirm `bats` is installed** + +```bash +which bats || sudo apt install -y bats +bats --version +``` + +Expected: prints a version (>= 1.7). + +- [ ] **Step 2: Write the failing tests** + +Create `tests-impl/test-compute-version.bats`: + +```bash +#!/usr/bin/env bats + +# Tests for scripts/compute-version.sh +# Invokes the real script against the real repo state. + +setup() { + REPO_ROOT="$(git rev-parse --show-toplevel)" + SCRIPT="$REPO_ROOT/scripts/compute-version.sh" +} + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "output matches '-seth' format" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" =~ ^[0-9].+-seth[0-9]+$ ]] +} + +@test "N=1 when no prior seth-tags exist" { + # This test assumes a clean tag db. If there ARE existing seth-tags, this + # test will report N>1 and that's the correct value. Skipped if any + # upstream-tag-seth* tag already exists. + upstream_tag=$(git describe --tags --abbrev=0 upstream/master) + existing=$(git tag --list "${upstream_tag}-seth*" | wc -l) + if [ "$existing" -gt 0 ]; then + skip "seth-tags already exist (count=$existing); N=1 invariant only holds on first release" + fi + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == "${upstream_tag}-seth1" ]] +} + +@test "N increments past existing seth-tags" { + upstream_tag=$(git describe --tags --abbrev=0 upstream/master) + # Create a fake seth-tag for this test, then clean up. + fake_tag="${upstream_tag}-seth99" + git tag "$fake_tag" 2>/dev/null || true + run "$SCRIPT" + git tag -d "$fake_tag" >/dev/null 2>&1 || true + [ "$status" -eq 0 ] + # Existing count was at least 1 (our fake), so N >= 2. + n="${output##*-seth}" + [ "$n" -ge 2 ] +} + +@test "fails cleanly when upstream/master ref is missing" { + # Run in a temp git repo with no upstream remote. + tmp=$(mktemp -d) + cd "$tmp" + git init -q + git commit --allow-empty -m "init" -q + run "$SCRIPT" + cd "$REPO_ROOT" + rm -rf "$tmp" + [ "$status" -ne 0 ] +} +``` + +- [ ] **Step 3: Run tests; confirm they fail** + +```bash +bats tests-impl/test-compute-version.bats +``` + +Expected: all tests fail because `scripts/compute-version.sh` doesn't exist yet. The first test (`script exists and is executable`) reports `[ -x "$SCRIPT" ]` failed. + +- [ ] **Step 4: Write the minimal script to pass the tests** + +Create `scripts/compute-version.sh`: + +```bash +#!/usr/bin/env bash +# Emit "-seth" version string to stdout. +# Pure logic: no side effects. +# +# CALLER RESPONSIBILITY (per spec §5.4): the local tag db must be fresh. +# If invoked outside the release flow, run `git fetch origin --tags` first +# or risk a stale value. +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.4 (D4) +set -euo pipefail + +upstream_tag=$(git describe --tags --abbrev=0 upstream/master) +existing_count=$(git tag --list "${upstream_tag}-seth*" | wc -l | tr -d ' ') +next_n=$((existing_count + 1)) +echo "${upstream_tag}-seth${next_n}" +``` + +```bash +chmod +x scripts/compute-version.sh +``` + +- [ ] **Step 5: Run tests; confirm they pass** + +```bash +bats tests-impl/test-compute-version.bats +``` + +Expected: all tests pass (or the `N=1` test reports `skip` if you've already tagged a release in your worktree, which is fine). + +- [ ] **Step 6: Sanity-check the actual output** + +```bash +./scripts/compute-version.sh +``` + +Expected: `3.99-master618-seth1` (or higher `seth` if you've tagged before). The `3.99-master618` part should match `git describe --tags --abbrev=0 upstream/master`. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/compute-version.sh tests-impl/test-compute-version.bats +git commit -m "feat: add compute-version.sh + bats tests" +``` + +--- + +## Task 3: `scripts/check-no-upstream-edits.sh` (TDD) + +**Files:** +- Test: `tests-impl/test-check-no-upstream-edits.bats` +- Create: `scripts/check-no-upstream-edits.sh` + +**What it does:** Enforces Invariant I1. Exits 0 silently on clean state; exits 1 with a clear error listing violations otherwise. Catches BOTH committed and uncommitted edits (spec §5.5). + +- [ ] **Step 1: Write the failing tests** + +Create `tests-impl/test-check-no-upstream-edits.bats`: + +```bash +#!/usr/bin/env bats + +# Tests for scripts/check-no-upstream-edits.sh + +setup() { + REPO_ROOT="$(git rev-parse --show-toplevel)" + SCRIPT="$REPO_ROOT/scripts/check-no-upstream-edits.sh" + TMP_REPO="" +} + +teardown() { + if [ -n "$TMP_REPO" ] && [ -d "$TMP_REPO" ]; then + rm -rf "$TMP_REPO" + fi +} + +# --- Helpers --- + +# Build a minimal disposable repo that mimics the sethLabels structure with a +# local "upstream/master" ref. Returns its path via stdout. +make_test_repo() { + local tmp=$(mktemp -d) + cd "$tmp" + git init -q -b master + git config user.email "test@test" + git config user.name "test" + # Pretend-upstream files + echo "upstream content" > UPSTREAM_FILE.md + echo "real source" > glabels-source.cpp + git add UPSTREAM_FILE.md glabels-source.cpp + git commit -m "upstream base" -q + # Create a local "upstream/master" ref pointing here + git update-ref refs/remotes/upstream/master HEAD + # Create a feature branch for sethLabels content + git checkout -q -b main + echo "$tmp" +} + +# --- Tests --- + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "exits 0 on clean state with only allowlisted committed changes" { + TMP_REPO=$(make_test_repo) + cd "$TMP_REPO" + mkdir -p scripts packaging sethlabels-docs .claude/handoffs + echo "test" > CLAUDE.md + echo "test" > scripts/something.sh + echo "test" > packaging/x.env + echo "test" > sethlabels-docs/spec.md + echo "" >> .gitignore + git add -A + git commit -m "sethLabels additions" -q + + run "$SCRIPT" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "exits 1 when an upstream file is committed-modified" { + TMP_REPO=$(make_test_repo) + cd "$TMP_REPO" + echo "evil edit" >> glabels-source.cpp + git add glabels-source.cpp + git commit -m "BAD: edit upstream file" -q + + run "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"glabels-source.cpp"* ]] + [[ "$output" == *"strict-zero"* ]] +} + +@test "exits 1 when an upstream file has uncommitted working-tree edits" { + TMP_REPO=$(make_test_repo) + cd "$TMP_REPO" + echo "uncommitted evil edit" >> glabels-source.cpp + + run "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"glabels-source.cpp"* ]] +} + +@test "exits 0 when only .gitignore is modified (allowlisted)" { + TMP_REPO=$(make_test_repo) + cd "$TMP_REPO" + echo "*.tmp" >> .gitignore + git add .gitignore + git commit -m "extend gitignore" -q + + run "$SCRIPT" + [ "$status" -eq 0 ] +} + +@test "exits 0 when only CLAUDE.md / IDEA.md / DECISIONS.md / README.sethlabels.md are added" { + TMP_REPO=$(make_test_repo) + cd "$TMP_REPO" + echo "x" > CLAUDE.md + echo "x" > IDEA.md + echo "x" > DECISIONS.md + echo "x" > README.sethlabels.md + git add -A + git commit -m "add sethLabels root docs" -q + + run "$SCRIPT" + [ "$status" -eq 0 ] +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +bats tests-impl/test-check-no-upstream-edits.bats +``` + +Expected: all tests fail (script doesn't exist). + +- [ ] **Step 3: Write the script to pass** + +Create `scripts/check-no-upstream-edits.sh`: + +```bash +#!/usr/bin/env bash +# Enforce Invariant I1: no upstream-tracked file is ever edited. +# Exits 0 on clean state, 1 on violation. +# +# Catches BOTH committed drift (commits unique to HEAD vs upstream/master) +# AND working-tree drift (uncommitted local edits to tracked files). +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.5 (I1, F1) +set -euo pipefail + +# Allowlist: files/dirs sethLabels is permitted to add or modify. +# `.gitignore` is the one upstream-file exception (called out in spec §2). +allowed_pattern='^\.gitignore$|^\.claude/|^scripts/|^packaging/|^sethlabels-docs/|^tests-impl/|^README\.sethlabels\.md$|^CLAUDE\.md$|^IDEA\.md$|^DECISIONS\.md$' + +committed=$(git diff --name-only upstream/master..HEAD 2>/dev/null || true) +working=$(git diff --name-only HEAD 2>/dev/null || true) + +all_changes=$(printf "%s\n%s\n" "$committed" "$working" | sort -u | sed '/^$/d') + +if [ -z "$all_changes" ]; then + exit 0 +fi + +violations=$(echo "$all_changes" | grep -vE "$allowed_pattern" || true) + +if [ -n "$violations" ]; then + echo "ERROR: strict-zero policy violated. The following upstream files have been modified:" + echo "$violations" + echo "" + echo "See sethlabels-docs/specs/2026-04-29-packaging-design.md §I1." + exit 1 +fi + +exit 0 +``` + +```bash +chmod +x scripts/check-no-upstream-edits.sh +``` + +- [ ] **Step 4: Run tests; confirm they pass** + +```bash +bats tests-impl/test-check-no-upstream-edits.bats +``` + +Expected: all 6 tests pass. + +- [ ] **Step 5: Run the guardrail against the real repo** + +```bash +./scripts/check-no-upstream-edits.sh && echo "CLEAN" +``` + +Expected: prints `CLEAN`. If it errors, you've accidentally touched an upstream file — investigate before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/check-no-upstream-edits.sh tests-impl/test-check-no-upstream-edits.bats +git commit -m "feat: add check-no-upstream-edits.sh + bats tests (enforces I1)" +``` + +--- + +## Task 4: `scripts/lib/deps-debian.sh` (TDD) + +**Files:** +- Test: `tests-impl/test-deps-debian.bats` +- Create: `scripts/lib/deps-debian.sh` + +**What it does:** Single source of truth for build deps (spec §5.1). When sourced, exposes `SETHLABELS_DEPS` array. When executed, checks each dep is installed and prints an actionable `apt install ...` command if anything is missing. + +- [ ] **Step 1: Write the failing tests** + +Create `tests-impl/test-deps-debian.bats`: + +```bash +#!/usr/bin/env bats + +# Tests for scripts/lib/deps-debian.sh + +setup() { + REPO_ROOT="$(git rev-parse --show-toplevel)" + SCRIPT="$REPO_ROOT/scripts/lib/deps-debian.sh" +} + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "sourceable: exposes SETHLABELS_DEPS array" { + source "$SCRIPT" + [ "${#SETHLABELS_DEPS[@]}" -gt 5 ] +} + +@test "SETHLABELS_DEPS contains core build tools" { + source "$SCRIPT" + [[ " ${SETHLABELS_DEPS[*]} " == *" cmake "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" ninja-build "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" build-essential "* ]] +} + +@test "SETHLABELS_DEPS contains Qt6 libraries" { + source "$SCRIPT" + [[ " ${SETHLABELS_DEPS[*]} " == *" qt6-base-dev "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" qt6-svg-dev "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" qt6-tools-dev "* ]] +} + +@test "SETHLABELS_DEPS contains barcode + zlib deps" { + source "$SCRIPT" + [[ " ${SETHLABELS_DEPS[*]} " == *" zlib1g-dev "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" libqrencode-dev "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" libzint-dev "* ]] +} + +@test "SETHLABELS_DEPS contains packaging tools" { + source "$SCRIPT" + [[ " ${SETHLABELS_DEPS[*]} " == *" dpkg-dev "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" fakeroot "* ]] + [[ " ${SETHLABELS_DEPS[*]} " == *" wget "* ]] +} + +@test "executed: prints status and exits 0 (when all installed) OR 1 with apt-install hint" { + run "$SCRIPT" + if [ "$status" -eq 0 ]; then + [[ "$output" == *"All build dependencies present"* ]] + else + [[ "$output" == *"sudo apt install"* ]] + fi +} + +@test "executed: warns if not on Debian/Ubuntu" { + # Simulate non-Debian by overriding /etc/os-release path via env var + if [ -f /etc/os-release ] && grep -qE '^ID=(debian|ubuntu)' /etc/os-release; then + skip "currently on Debian/Ubuntu — non-Debian path covered by source review" + fi + run "$SCRIPT" + [[ "$output" == *"Debian"* || "$output" == *"Ubuntu"* ]] +} +``` + +- [ ] **Step 2: Run tests; confirm they fail** + +```bash +bats tests-impl/test-deps-debian.bats +``` + +Expected: all fail. + +- [ ] **Step 3: Write the script to pass** + +Create `scripts/lib/deps-debian.sh`: + +```bash +#!/usr/bin/env bash +# Single source of truth for sethLabels build dependencies on Debian-family Linux. +# +# When SOURCED: exposes SETHLABELS_DEPS array (no side effects). +# When EXECUTED: verifies each dep is installed; prints actionable +# `sudo apt install ...` command on missing deps; exits 1. +# On clean state, prints "All build dependencies present." and exits 0. +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.1 +set -euo pipefail + +SETHLABELS_DEPS=( + build-essential cmake ninja-build pkg-config + qt6-base-dev qt6-base-dev-tools + qt6-svg-dev + qt6-tools-dev qt6-tools-dev-tools + qt6-l10n-tools + libqt6printsupport6 libqt6svg6 libqt6widgets6 libqt6xml6 libqt6gui6 + libqt6concurrent6 libqt6core6 libqt6test6 + zlib1g-dev libqrencode-dev libzint-dev libgnubarcode-dev + file dpkg-dev fakeroot + wget + bats +) + +# Detect sourced vs. executed. +# When sourced: BASH_SOURCE[0] != $0 +# When executed: BASH_SOURCE[0] == $0 +if [ "${BASH_SOURCE[0]}" != "${0}" ]; then + return 0 2>/dev/null || exit 0 +fi + +# --- Executed path --- + +# Sanity check the build host +if [ ! -f /etc/os-release ]; then + echo "WARNING: /etc/os-release missing; not Debian-family. This script is designed for Debian 13 / Ubuntu LTS." >&2 +fi + +if [ -f /etc/os-release ]; then + . /etc/os-release + if [[ "${ID:-}" != "debian" && "${ID:-}" != "ubuntu" ]] && [[ "${ID_LIKE:-}" != *debian* && "${ID_LIKE:-}" != *ubuntu* ]]; then + echo "WARNING: not running on Debian or Ubuntu (detected ID='${ID:-unknown}'). Build deps may differ." >&2 + fi +fi + +missing=() +for pkg in "${SETHLABELS_DEPS[@]}"; do + if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then + missing+=("$pkg") + fi +done + +if [ "${#missing[@]}" -gt 0 ]; then + echo "Missing build dependencies (${#missing[@]}):" + for p in "${missing[@]}"; do + echo " - $p" + done + echo "" + echo "Install with:" + echo " sudo apt install -y ${missing[*]}" + exit 1 +fi + +echo "All build dependencies present (${#SETHLABELS_DEPS[@]} packages verified)." +``` + +```bash +chmod +x scripts/lib/deps-debian.sh +``` + +- [ ] **Step 4: Run tests; confirm they pass** + +```bash +bats tests-impl/test-deps-debian.bats +``` + +Expected: all 8 tests pass. + +- [ ] **Step 5: Run the script directly** + +```bash +./scripts/lib/deps-debian.sh +``` + +Expected: either `All build dependencies present.` (if you've installed everything) OR a `Missing build dependencies` listing followed by an `apt install` command. If missing, copy the printed command and run it now — it's needed for Tasks 7 and 8. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/lib/deps-debian.sh tests-impl/test-deps-debian.bats +git commit -m "feat: add deps-debian.sh (build-dep manifest + checker)" +``` + +--- + +## Task 5: `scripts/lib/linuxdeploy.sh` + +**Files:** +- Create: `scripts/lib/linuxdeploy.sh` + +**What it does:** Bootstraps `linuxdeploy` and `linuxdeploy-plugin-qt` to a script-local cache (`scripts/.cache/`) on first run. Pinned versions per spec §F9 (no rolling `continuous` tag). + +This task does NOT use TDD because it makes network calls. We validate it by running it and checking outputs. + +- [ ] **Step 1: Discover the latest pinned tags from GitHub** + +Per spec §F9, we must pin specific versions. Query GitHub for the latest releases: + +```bash +curl -s https://api.github.com/repos/linuxdeploy/linuxdeploy/releases/latest | grep -E '"tag_name"' | head -1 +curl -s https://api.github.com/repos/linuxdeploy/linuxdeploy-plugin-qt/releases/latest | grep -E '"tag_name"' | head -1 +``` + +Record both tag values. As of spec time (2026-04-29) the linuxdeploy project uses rolling `continuous` releases plus dated `1-alpha-*` snapshots; pick the most recent dated `1-alpha-YYYYMMDD-N` tag from the releases page (NOT `continuous` — `continuous` violates F9). If only `continuous` is offered for the qt plugin, fall back to its `master`-pinned commit SHA noted in the response. + +For the rest of this task, substitute your discovered tags as `LINUXDEPLOY_TAG` and `LINUXDEPLOY_PLUGIN_QT_TAG` in the script below. + +- [ ] **Step 2: Write the script** + +Create `scripts/lib/linuxdeploy.sh` (replace the two `` placeholders below with the actual tags discovered in Step 1): + +```bash +#!/usr/bin/env bash +# Bootstrap linuxdeploy + linuxdeploy-plugin-qt to a script-local cache. +# +# When SOURCED: exposes $LINUXDEPLOY_BIN and $LINUXDEPLOY_PLUGIN_QT_BIN paths +# (downloads on first run if missing). +# When EXECUTED: ensures both binaries are present and prints their paths. +# +# Pinned per spec §F9 — version bumps are deliberate, not transparent. +# To bump: re-run discovery (Task 5 Step 1 of the implementation plan), update +# the two TAG variables below, and verify a fresh AppImage build. +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §F9 +set -euo pipefail + +# === PINNED VERSIONS (update deliberately per F9) === +LINUXDEPLOY_TAG="" +LINUXDEPLOY_PLUGIN_QT_TAG="" +# ==================================================== + +CACHE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.cache" +LINUXDEPLOY_BIN="$CACHE_DIR/linuxdeploy-${LINUXDEPLOY_TAG}-x86_64.AppImage" +LINUXDEPLOY_PLUGIN_QT_BIN="$CACHE_DIR/linuxdeploy-plugin-qt-${LINUXDEPLOY_PLUGIN_QT_TAG}-x86_64.AppImage" + +LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/${LINUXDEPLOY_TAG}/linuxdeploy-x86_64.AppImage" +LINUXDEPLOY_PLUGIN_QT_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/${LINUXDEPLOY_PLUGIN_QT_TAG}/linuxdeploy-plugin-qt-x86_64.AppImage" + +ensure_tool() { + local url="$1" out="$2" label="$3" + if [ -x "$out" ]; then + return 0 + fi + mkdir -p "$(dirname "$out")" + echo "Downloading $label from $url ..." >&2 + if ! wget -q --show-progress -O "$out" "$url"; then + echo "ERROR: download failed for $label ($url)" >&2 + rm -f "$out" + return 1 + fi + chmod +x "$out" +} + +ensure_tool "$LINUXDEPLOY_URL" "$LINUXDEPLOY_BIN" "linuxdeploy" +ensure_tool "$LINUXDEPLOY_PLUGIN_QT_URL" "$LINUXDEPLOY_PLUGIN_QT_BIN" "linuxdeploy-plugin-qt" + +export LINUXDEPLOY_BIN LINUXDEPLOY_PLUGIN_QT_BIN + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + echo "linuxdeploy: $LINUXDEPLOY_BIN" + echo "linuxdeploy-plugin-qt: $LINUXDEPLOY_PLUGIN_QT_BIN" +fi +``` + +```bash +chmod +x scripts/lib/linuxdeploy.sh +``` + +- [ ] **Step 3: Verify the cache dir is gitignored** + +```bash +git check-ignore -v scripts/.cache/anything 2>&1 || echo "NOT IGNORED (problem)" +``` + +Expected: shows that `scripts/.cache/` matches a `.gitignore` rule (added in Task 1 Step 3). If it says "NOT IGNORED", revisit Task 1 Step 3. + +- [ ] **Step 4: Run the script (downloads ~30MB on first run)** + +```bash +./scripts/lib/linuxdeploy.sh +``` + +Expected: prints two paths under `scripts/.cache/`. Both files must be executable AppImages. + +- [ ] **Step 5: Smoke-verify the downloaded tools work** + +```bash +"$(./scripts/lib/linuxdeploy.sh | head -1 | awk '{print $2}')" --version +``` + +Expected: prints linuxdeploy version banner. If FUSE is not available (some VMs / containers), set `APPIMAGE_EXTRACT_AND_RUN=1`: + +```bash +APPIMAGE_EXTRACT_AND_RUN=1 "$LINUXDEPLOY_BIN" --version +``` + +If this fails, ensure `libfuse2` is installed (`sudo apt install libfuse2`) or use the extract-and-run env var. + +- [ ] **Step 6: Re-run to verify caching (no re-download)** + +```bash +./scripts/lib/linuxdeploy.sh +``` + +Expected: instant exit, no `Downloading ...` messages. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/lib/linuxdeploy.sh +git commit -m "feat: add linuxdeploy.sh bootstrap (pinned per F9)" +``` + +--- + +## Task 6: Packaging metadata files + +**Files:** +- Create: `packaging/deb-metadata.env` +- Create: `packaging/appimage-recipe.env` +- Create: `packaging/changelog.md` + +**What they do:** Static configuration files consumed by the build scripts. Keeping these out of the shell scripts means tweaking maintainer info, brew tap pins, or release notes is a single-line edit, not a script change. + +- [ ] **Step 1: Create `packaging/deb-metadata.env`** + +```bash +cat > packaging/deb-metadata.env <<'EOF' +# CPack DEB metadata overrides — sourced by scripts/build-deb.sh. +# All values are passed to cpack as -D CPACK_DEBIAN_PACKAGE_="$VALUE". +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.2 + +MAINTAINER="Seth Freiberg " +SECTION="graphics" +HOMEPAGE="https://glabels.org" +# CPACK_PACKAGE_NAME override — required because upstream sets +# CPACK_PACKAGE_NAME=glabels (CMakeLists.txt:86) and decision D6 wants glabels-qt. +PACKAGE_NAME="glabels-qt" +EOF +``` + +- [ ] **Step 2: Create `packaging/appimage-recipe.env`** + +```bash +cat > packaging/appimage-recipe.env <<'EOF' +# linuxdeploy / linuxdeploy-plugin-qt configuration — sourced by scripts/build-appimages.sh. +# Documents the bundling choices for both AppImages. +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.3 + +# Qt platform plugins to include (linuxdeploy-plugin-qt picks these up automatically; +# documenting here for posterity). +QT_PLATFORM_PLUGINS="xcb" + +# Image format plugins. The GUI app needs SVG/PNG support; batch CLI does not. +QT_IMAGE_FORMAT_PLUGINS_GUI="svg" +QT_IMAGE_FORMAT_PLUGINS_BATCH="" + +# Files in the AppDir we don't want bundled (linuxdeploy is greedy by default). +APPDIR_EXCLUDE_GLOBS=() +EOF +``` + +- [ ] **Step 3: Create `packaging/changelog.md`** + +```bash +cat > packaging/changelog.md <<'EOF' +# sethLabels packaging changelog + +Per-release packaging notes. Each entry covers what changed in the *packaging*, +not what changed upstream. For application-level changes, see the upstream +`docs/CHANGELOG.md` and `git log upstream/master`. + +## Format + +``` +## + +- bullet describing what changed in this packaging release +- ... +``` + +`` is `-seth` (e.g., `3.99-master618-seth1`), matching +the git tag and the artifact filename. See spec §D4. + +--- + +## (unreleased) + +- First end-to-end release dry run pending. +EOF +``` + +- [ ] **Step 4: Smoke-source the env files** + +```bash +( source packaging/deb-metadata.env && echo "MAINTAINER=$MAINTAINER" && echo "PACKAGE_NAME=$PACKAGE_NAME" ) +( source packaging/appimage-recipe.env && echo "QT_PLATFORM_PLUGINS=$QT_PLATFORM_PLUGINS" ) +``` + +Expected: prints the values, no errors. If `set -u` is on globally, sourcing should not error (no unset vars referenced). + +- [ ] **Step 5: Commit** + +```bash +git add packaging/ +git commit -m "feat: add packaging metadata + initial changelog" +``` + +--- + +## Task 7: `scripts/build-deb.sh` (with inline smoke tests T1, T2) + +**Files:** +- Create: `scripts/build-deb.sh` + +**What it does:** End-to-end driver that produces `build/deb/glabels-qt_${VERSION}_amd64.deb` and runs smoke tests T1 (parse-ability) and T2 (binaries present) inline. Aborts with a clear error on any failure. + +- [ ] **Step 1: Write the script** + +Create `scripts/build-deb.sh`: + +```bash +#!/usr/bin/env bash +# Build the sethLabels .deb package. +# +# Pipeline (spec §5.2): +# 1. sanity check build host (Debian/Ubuntu, deps present) +# 2. strict-zero guardrail +# 3. compute version +# 4. out-of-tree cmake build +# 5. CPack with overrides +# 6. inline smoke tests T1, T2 +# 7. print artifact path for the operator +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.2 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo "==> [1/6] Sanity check build host" +"$REPO_ROOT/scripts/lib/deps-debian.sh" + +echo "==> [2/6] Strict-zero guardrail" +"$REPO_ROOT/scripts/check-no-upstream-edits.sh" + +echo "==> [3/6] Compute version" +VERSION="$("$REPO_ROOT/scripts/compute-version.sh")" +echo " VERSION = $VERSION" + +echo "==> [4/6] Out-of-tree cmake build" +BUILD_DIR="$REPO_ROOT/build/deb" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" +cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build "$BUILD_DIR" --parallel + +echo "==> [5/6] CPack DEB generation" +# shellcheck disable=SC1091 +source "$REPO_ROOT/packaging/deb-metadata.env" + +cd "$BUILD_DIR" +cpack -G DEB \ + -D CPACK_PACKAGE_NAME="$PACKAGE_NAME" \ + -D CPACK_PACKAGE_VERSION="$VERSION" \ + -D CPACK_DEBIAN_PACKAGE_NAME="$PACKAGE_NAME" \ + -D CPACK_DEBIAN_PACKAGE_MAINTAINER="$MAINTAINER" \ + -D CPACK_DEBIAN_PACKAGE_SECTION="$SECTION" \ + -D CPACK_DEBIAN_PACKAGE_HOMEPAGE="$HOMEPAGE" \ + -D CPACK_DEBIAN_PACKAGE_SHLIBDEPS=ON \ + -D CPACK_DEBIAN_FILE_NAME=DEB-DEFAULT +cd "$REPO_ROOT" + +# Resolve the actual artifact filename (CPack uses DEB-DEFAULT naming convention). +DEB_ARTIFACT=$(ls "$BUILD_DIR"/${PACKAGE_NAME}_*.deb 2>/dev/null | head -1) +if [ -z "$DEB_ARTIFACT" ] || [ ! -f "$DEB_ARTIFACT" ]; then + echo "ERROR: expected .deb artifact not found in $BUILD_DIR" >&2 + ls -la "$BUILD_DIR" >&2 + exit 1 +fi + +echo "==> [6/6] Smoke tests" + +# T1: dpkg-deb --info parses, version field matches. +echo " T1: dpkg-deb --info" +T1_OUT=$(dpkg-deb --info "$DEB_ARTIFACT") +if ! echo "$T1_OUT" | grep -qE "^ Version: ${VERSION}$"; then + echo "ERROR: T1 failed — version field in .deb does not match \$VERSION=$VERSION" >&2 + echo "$T1_OUT" >&2 + exit 1 +fi +echo " T1: PASS" + +# T2: dpkg-deb --contents includes both binaries. +echo " T2: dpkg-deb --contents" +T2_OUT=$(dpkg-deb --contents "$DEB_ARTIFACT") +if ! echo "$T2_OUT" | grep -q '/usr/bin/glabels-qt'; then + echo "ERROR: T2 failed — /usr/bin/glabels-qt missing from .deb" >&2 + exit 1 +fi +if ! echo "$T2_OUT" | grep -q '/usr/bin/glabels-batch-qt'; then + echo "ERROR: T2 failed — /usr/bin/glabels-batch-qt missing from .deb" >&2 + exit 1 +fi +echo " T2: PASS" + +# Optional: lintian (warnings-only, non-fatal during battle-test). +if command -v lintian >/dev/null 2>&1; then + echo " lintian (advisory):" + lintian "$DEB_ARTIFACT" || true +fi + +echo "" +echo "Artifact: $DEB_ARTIFACT" +``` + +```bash +chmod +x scripts/build-deb.sh +``` + +- [ ] **Step 2: Run the build** + +```bash +./scripts/build-deb.sh +``` + +Expected: walks through all 6 steps, ends with `Artifact: build/deb/glabels-qt_3.99-master618-seth1_amd64.deb`. Wall time ~2 minutes on a modern machine. + +If T1 fails: investigate whether `CPACK_PACKAGE_VERSION` was applied correctly; some upstream `CMakeLists.txt` edits may need a clean build. + +If T2 fails: check `cmake --install --prefix=/tmp/install build/deb && ls /tmp/install/usr/bin/` to verify upstream's install rules produced both binaries. If only one is present, it's an upstream issue (not a sethLabels bug). + +- [ ] **Step 3: Inspect the artifact manually** + +```bash +DEB=$(ls build/deb/glabels-qt_*.deb | head -1) +dpkg-deb --info "$DEB" +dpkg-deb --contents "$DEB" | head -30 +``` + +Expected: `Package: glabels-qt`, `Version: 3.99-master618-seth1` (or your computed version), and a sane file listing showing `/usr/bin/...`, `/usr/share/applications/...`, etc. + +- [ ] **Step 4: Optionally test-install on the build host (non-destructive smoke)** + +```bash +sudo apt install -y "./$DEB" +glabels-qt --version +sudo apt remove -y glabels-qt +``` + +Skip if you'd rather only test on a clean VM (T5 in the release flow). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build-deb.sh +git commit -m "feat: add build-deb.sh with inline smoke tests T1, T2" +``` + +--- + +## Task 8: `scripts/build-appimages.sh` (with inline smoke tests T3, T4) + +**Files:** +- Create: `scripts/build-appimages.sh` + +**What it does:** Produces TWO AppImages — `sethlabels-gui-${VERSION}-x86_64.AppImage` (full Qt6 GUI) and `sethlabels-batch-${VERSION}-x86_64.AppImage` (CLI batch tool, leaner) — using `linuxdeploy` + `linuxdeploy-plugin-qt`. Inline smoke tests T3 (batch `--version`) and T4 (gui `--help` under `QT_QPA_PLATFORM=minimal`). + +- [ ] **Step 1: Write the script** + +Create `scripts/build-appimages.sh`: + +```bash +#!/usr/bin/env bash +# Build sethLabels AppImages (GUI + batch). +# +# Pipeline (spec §5.3): +# 1. sanity / guardrail / version-compute (same as build-deb.sh) +# 2. out-of-tree cmake build with CMAKE_INSTALL_PREFIX=/usr +# 3. cmake --install to staging AppDir +# 4. linuxdeploy bundle GUI AppImage +# 5. re-stage AppDir for batch-only, linuxdeploy bundle batch AppImage +# 6. inline smoke tests T3, T4 +# 7. print artifact paths +# +# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.3 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo "==> [1/6] Sanity check build host" +"$REPO_ROOT/scripts/lib/deps-debian.sh" +echo "==> [1/6] Strict-zero guardrail" +"$REPO_ROOT/scripts/check-no-upstream-edits.sh" +echo "==> [1/6] Compute version" +VERSION="$("$REPO_ROOT/scripts/compute-version.sh")" +echo " VERSION = $VERSION" + +# Bootstrap linuxdeploy + plugin-qt; defines $LINUXDEPLOY_BIN and $LINUXDEPLOY_PLUGIN_QT_BIN. +# shellcheck disable=SC1091 +source "$REPO_ROOT/scripts/lib/linuxdeploy.sh" +# linuxdeploy looks for the plugin in PATH; symlink into the cache dir suffices. +PLUGIN_DIR="$(dirname "$LINUXDEPLOY_PLUGIN_QT_BIN")" +PATH="$PLUGIN_DIR:$PATH" +# Plugin file must be named exactly `linuxdeploy-plugin-qt` (no version suffix). +PLUGIN_LINK="$PLUGIN_DIR/linuxdeploy-plugin-qt" +ln -sf "$LINUXDEPLOY_PLUGIN_QT_BIN" "$PLUGIN_LINK" +chmod +x "$PLUGIN_LINK" + +echo "==> [2/6] Out-of-tree cmake build (install prefix /usr)" +BUILD_DIR="$REPO_ROOT/build/appimage" +APPDIR_GUI="$BUILD_DIR/AppDir-gui" +APPDIR_BATCH="$BUILD_DIR/AppDir-batch" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" +cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr +cmake --build "$BUILD_DIR" --parallel + +echo "==> [3/6] Stage install tree to AppDirs" +DESTDIR="$APPDIR_GUI" cmake --install "$BUILD_DIR" +# Batch AppDir gets its own copy so we can prune Qt plugins that GUI needs but batch doesn't. +DESTDIR="$APPDIR_BATCH" cmake --install "$BUILD_DIR" + +# Sanity: both AppDirs must contain both binaries (we strip later, not here). +test -x "$APPDIR_GUI/usr/bin/glabels-qt" || { echo "ERROR: GUI binary missing in AppDir-gui" >&2; exit 1; } +test -x "$APPDIR_BATCH/usr/bin/glabels-batch-qt" || { echo "ERROR: batch binary missing in AppDir-batch" >&2; exit 1; } + +echo "==> [4/6] Bundle GUI AppImage" +DESKTOP_FILE="$APPDIR_GUI/usr/share/applications/glabels-qt.desktop" +ICON_FILE="$APPDIR_GUI/usr/share/icons/hicolor/scalable/apps/glabels.svg" +# Upstream's actual desktop filename may vary — list what's there if missing. +if [ ! -f "$DESKTOP_FILE" ]; then + ALT_DESKTOP=$(find "$APPDIR_GUI/usr/share/applications" -name '*.desktop' | head -1) + if [ -n "$ALT_DESKTOP" ]; then + DESKTOP_FILE="$ALT_DESKTOP" + else + echo "ERROR: no .desktop file found in $APPDIR_GUI/usr/share/applications" >&2 + exit 1 + fi +fi + +cd "$BUILD_DIR" +APPIMAGE_EXTRACT_AND_RUN=1 \ +"$LINUXDEPLOY_BIN" \ + --appdir "$APPDIR_GUI" \ + --plugin qt \ + --executable "$APPDIR_GUI/usr/bin/glabels-qt" \ + --desktop-file "$DESKTOP_FILE" \ + --icon-file "$ICON_FILE" \ + --output appimage + +GUI_RAW=$(ls "$BUILD_DIR"/*GUI*.AppImage "$BUILD_DIR"/*glabels-qt*.AppImage 2>/dev/null | head -1) +GUI_OUT="$REPO_ROOT/sethlabels-gui-${VERSION}-x86_64.AppImage" +mv "$GUI_RAW" "$GUI_OUT" +chmod +x "$GUI_OUT" +cd "$REPO_ROOT" + +echo "==> [5/6] Bundle batch AppImage" +# Batch doesn't need a desktop file or icon (CLI only). +cd "$BUILD_DIR" +APPIMAGE_EXTRACT_AND_RUN=1 \ +"$LINUXDEPLOY_BIN" \ + --appdir "$APPDIR_BATCH" \ + --plugin qt \ + --executable "$APPDIR_BATCH/usr/bin/glabels-batch-qt" \ + --create-desktop-file \ + --output appimage + +BATCH_RAW=$(ls "$BUILD_DIR"/*batch*.AppImage 2>/dev/null | head -1) +BATCH_OUT="$REPO_ROOT/sethlabels-batch-${VERSION}-x86_64.AppImage" +mv "$BATCH_RAW" "$BATCH_OUT" +chmod +x "$BATCH_OUT" +cd "$REPO_ROOT" + +echo "==> [6/6] Smoke tests" + +# T3: batch AppImage --version exits 0 with non-empty output. +echo " T3: batch --version" +T3_OUT=$(APPIMAGE_EXTRACT_AND_RUN=1 "$BATCH_OUT" --version 2>&1) || { + echo "ERROR: T3 failed — batch AppImage --version exited non-zero" >&2 + echo "$T3_OUT" >&2 + exit 1 +} +if [ -z "$T3_OUT" ]; then + echo "ERROR: T3 failed — batch AppImage --version produced empty output" >&2 + exit 1 +fi +echo " T3: PASS ($(echo "$T3_OUT" | head -1))" + +# T4: GUI AppImage --help exits 0 under headless Qt platform. +echo " T4: gui --help (QT_QPA_PLATFORM=minimal)" +APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=minimal "$GUI_OUT" --help >/tmp/sethlabels-gui-help 2>&1 || { + echo "ERROR: T4 failed — GUI AppImage --help exited non-zero" >&2 + cat /tmp/sethlabels-gui-help >&2 + exit 1 +} +echo " T4: PASS" + +echo "" +echo "Artifacts:" +echo " $GUI_OUT" +echo " $BATCH_OUT" +``` + +```bash +chmod +x scripts/build-appimages.sh +``` + +- [ ] **Step 2: Run the build** + +```bash +./scripts/build-appimages.sh +``` + +Expected: walks through all 6 steps, ends with two artifact paths. Wall time ~5 minutes on a modern machine. If `linuxdeploy` errors with "no $XDG_RUNTIME_DIR" or similar, set the env var: `export XDG_RUNTIME_DIR=/tmp/runtime-$USER && mkdir -p $XDG_RUNTIME_DIR`. + +If T3 or T4 fails: see spec §F2 — Qt plugin omissions are the usual culprit. Re-run with `LINUXDEPLOY_OUTPUT_VERSION=$VERSION` and inspect the bundle's `usr/plugins/` dir for missing platform/imageformats plugins. + +- [ ] **Step 3: Smoke-test the artifacts manually** + +```bash +GUI_OUT=$(ls sethlabels-gui-*.AppImage | head -1) +BATCH_OUT=$(ls sethlabels-batch-*.AppImage | head -1) +ls -la "$GUI_OUT" "$BATCH_OUT" +APPIMAGE_EXTRACT_AND_RUN=1 "$BATCH_OUT" --version +APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=minimal "$GUI_OUT" --help | head -10 +``` + +Expected: GUI is ~50–80 MB (bundles Qt6), batch is ~20–40 MB (no GUI plugins). Both run cleanly. + +- [ ] **Step 4: Verify .gitignore catches the AppImages** + +```bash +git status +``` + +Expected: no `*.AppImage` files appear in the untracked list (matched by the `*.AppImage` rule added in Task 1 Step 3). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build-appimages.sh +git commit -m "feat: add build-appimages.sh with inline smoke tests T3, T4" +``` + +--- + +## Task 9: `scripts/README.md` + +**Files:** +- Create: `scripts/README.md` + +**What it does:** Operator-facing run instructions. Documents the order to run scripts, prerequisites, and where artifacts land. Mirrored as the canonical recipe for future CI YAML wrapping (spec I3). + +- [ ] **Step 1: Write `scripts/README.md`** + +```bash +cat > scripts/README.md <<'EOF' +# sethLabels build scripts + +Canonical recipe for building sethLabels artifacts. CI YAML at the public-flip +will call these scripts unmodified — no logic moves into YAML (spec §I3). + +## Quick reference + +``` +./scripts/lib/deps-debian.sh # check / install build deps +./scripts/check-no-upstream-edits.sh # enforce strict-zero (I1) +./scripts/compute-version.sh # emit -seth +./scripts/build-deb.sh # → build/deb/glabels-qt__amd64.deb +./scripts/build-appimages.sh # → sethlabels-{gui,batch}--x86_64.AppImage +``` + +## Prerequisites + +Debian 13 (Trixie) or Ubuntu 24.04 LTS. Run: + +``` +./scripts/lib/deps-debian.sh +``` + +If anything is missing, the script prints the exact `sudo apt install ...` +command to run. + +`bats` (bash test framework) is in the dep list — it's required for the +implementation tests under `tests-impl/`. + +`linuxdeploy` and `linuxdeploy-plugin-qt` are NOT apt-installable; they're +downloaded automatically by `scripts/lib/linuxdeploy.sh` to `scripts/.cache/` +on first AppImage build. + +## Versioning + +`-seth` (e.g., `3.99-master618-seth1`). The `` counter is +computed from existing git tags matching `-seth*`. See spec §D4. + +**Caller responsibility:** the local tag db must be fresh before running +`compute-version.sh`. Run `git fetch origin --tags` first if you're not +inside the release flow (which fetches tags as step 1). + +## Release flow + +See spec §6 for the canonical step-by-step. TL;DR: + +``` +git fetch --all --tags +git rebase upstream/master +./scripts/check-no-upstream-edits.sh +./scripts/build-deb.sh # ~2 min +./scripts/build-appimages.sh # ~5 min +VERSION=$(./scripts/compute-version.sh) +git tag "$VERSION" +git push origin main --tags +# Create Gitea release for $VERSION; attach the three artifacts. +# Bump ../homebrew-tap/Formula/glabels-qt.rb (tag + revision); commit; push. +# Smoke verify on a clean Debian 13 VM (T5). +``` + +## Layout + +``` +scripts/ +├── README.md ← this file +├── compute-version.sh ← pure logic; emits version string +├── check-no-upstream-edits.sh ← guardrail enforcing I1 +├── build-deb.sh ← end-to-end .deb pipeline +├── build-appimages.sh ← end-to-end AppImage pipeline (GUI + batch) +├── lib/ +│ ├── deps-debian.sh ← build-dep manifest + checker +│ └── linuxdeploy.sh ← linuxdeploy + plugin-qt bootstrapper +└── .cache/ ← gitignored; linuxdeploy AppImages cache +``` + +## Tests + +``` +./tests-impl/run-all.sh +``` + +Runs the bats suite for pure-logic scripts. Build-script smoke tests (T1–T4) +are inline in `build-deb.sh` and `build-appimages.sh` — they fire automatically +on each build. + +## Spec + +The design rationale, invariants, and failure modes live in +[`../sethlabels-docs/specs/2026-04-29-packaging-design.md`](../sethlabels-docs/specs/2026-04-29-packaging-design.md). +Read it before changing any script. +EOF +``` + +- [ ] **Step 2: Verify the README renders sensibly** + +```bash +head -40 scripts/README.md +``` + +Expected: clean Markdown, no obvious typos. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/README.md +git commit -m "docs: add scripts/README.md (operator run guide)" +``` + +--- + +## Task 10: `README.sethlabels.md` (repo-root entry) + +**Files:** +- Create: `README.sethlabels.md` + +**What it does:** Repo-root sethLabels entry point. Names the fork's purpose, points readers at install methods, and links to the upstream README and design spec. Strict-zero forbids modifying upstream `README.md`, hence the `.sethlabels.md` suffix. + +- [ ] **Step 1: Write `README.sethlabels.md`** + +```bash +cat > README.sethlabels.md <<'EOF' +# sethLabels + +> Deployment fork of [glabels-qt](https://github.com/j-evins/glabels-qt) — Qt6 +> label designer / printer, packaged for Debian-family Linux and macOS. + +This is **not** a code fork. The upstream application is unchanged; sethLabels +exists solely to publish installable binary artifacts that upstream explicitly +does not provide ("Currently there are no self-hosted binary snapshot releases +available… I encourage you to try building the code yourself" — upstream README). + +For the application itself — what it does, screenshots, full feature list — see +the upstream [`README.md`](README.md). + +## Install + +### Debian / Ubuntu (`.deb`) + +Download the latest `.deb` from the [releases page](https://git.sethpc.xyz/Seth/sethLabels/releases), +then: + +``` +sudo apt install ./glabels-qt__amd64.deb +glabels-qt --version +``` + +### Any Linux (AppImage) + +Download `sethlabels-gui--x86_64.AppImage` from the [releases page](https://git.sethpc.xyz/Seth/sethLabels/releases), +make it executable, and run it: + +``` +chmod +x sethlabels-gui--x86_64.AppImage +./sethlabels-gui--x86_64.AppImage +``` + +A separate `sethlabels-batch--x86_64.AppImage` provides the CLI for +scripted / mail-merge use. + +### macOS (Homebrew) + +``` +brew tap seth/tap https://git.sethpc.xyz/Seth/homebrew-tap.git +brew install seth/tap/glabels-qt +``` + +The explicit URL form is needed because brew defaults to GitHub for tap names. +First install builds Qt6 + glabels-qt from source (~5–10 min one-time cost; see +spec §D2). Subsequent updates are a fast `brew upgrade`. + +## Build from source + +If you'd rather build the artifacts yourself instead of downloading a release: + +``` +git clone https://git.sethpc.xyz/Seth/sethLabels.git +cd sethLabels +./scripts/lib/deps-debian.sh # check / install build deps +./scripts/build-deb.sh # → build/deb/glabels-qt_*.deb +./scripts/build-appimages.sh # → sethlabels-{gui,batch}-*.AppImage +``` + +See [`scripts/README.md`](scripts/README.md) for full operator docs. + +## How this fork works + +sethLabels is a **deployment fork**: every sethLabels addition lives in NEW +files in NEW top-level directories (`scripts/`, `packaging/`, +`sethlabels-docs/`, `tests-impl/`, plus this file). Upstream files are never +edited. The single allowlisted exception is `.gitignore`. This discipline is +enforced by `scripts/check-no-upstream-edits.sh`. + +The `-seth` versioning preserves the upstream-lineage in every +artifact. Periodic `git rebase upstream/master` is conflict-free by construction. + +## Spec & decisions + +- [Design spec](sethlabels-docs/specs/2026-04-29-packaging-design.md) — invariants, decisions, build pipeline, failure modes +- [Decision log](DECISIONS.md) — settled choices + rejected alternatives +- [Project brief](IDEA.md) — plain-language motivation + +## License + +The upstream code is licensed under [GPL-3.0](LICENSE). sethLabels-specific +files (everything in the dirs listed above, plus this file) are licensed under +the same terms. + +## Upstream + +- Upstream: https://github.com/j-evins/glabels-qt (Jaye Evins / glabels.org) +- This fork: https://git.sethpc.xyz/Seth/sethLabels +- Brew tap: https://git.sethpc.xyz/Seth/homebrew-tap +EOF +``` + +- [ ] **Step 2: Verify guardrail still passes** + +```bash +./scripts/check-no-upstream-edits.sh && echo CLEAN +``` + +Expected: `CLEAN`. `README.sethlabels.md` is in the allowlist (added in Task 3 Step 3's `allowed_pattern`). + +- [ ] **Step 3: Commit** + +```bash +git add README.sethlabels.md +git commit -m "docs: add README.sethlabels.md (fork entry point)" +``` + +--- + +## Task 11: Homebrew tap repo (separate Gitea repo) + +**Files (in a separate repo at `~/bin/homebrew-tap/`):** +- Create: `~/bin/homebrew-tap/Formula/glabels-qt.rb` +- Create: `~/bin/homebrew-tap/README.md` +- Create: Gitea repo `git.sethpc.xyz/Seth/homebrew-tap` + +**What it does:** Provides the macOS install path per spec §7 + §D2. Build-from-source on the user's Mac via brew. + +This task does NOT modify the sethLabels repo. It creates a parallel sibling repo. + +- [ ] **Step 1: Create the local repo skeleton** + +```bash +mkdir -p ~/bin/homebrew-tap/Formula +cd ~/bin/homebrew-tap +git init -q -b main +git config user.email "seth@sethfreiberg.com" +git config user.name "Seth Freiberg" +``` + +- [ ] **Step 2: Create `Formula/glabels-qt.rb`** + +```bash +cat > Formula/glabels-qt.rb <<'EOF' +class GlabelsQt < Formula + desc "gLabels Label Designer (Qt/C++) — Seth's packaging fork" + homepage "https://glabels.org" + url "https://git.sethpc.xyz/Seth/sethLabels.git", + tag: "PLACEHOLDER_FILLED_AT_FIRST_RELEASE", + revision: "PLACEHOLDER_FILLED_AT_FIRST_RELEASE" + license "GPL-3.0-only" + head "https://git.sethpc.xyz/Seth/sethLabels.git", branch: "main" + + depends_on "cmake" => :build + depends_on "ninja" => :build + depends_on "pkgconf" => :build + depends_on "qt" + depends_on "zlib" + depends_on "qrencode" => :recommended # optional barcode backend + depends_on "zint" => :recommended # optional barcode backend + + def install + system "cmake", "-S", ".", "-B", "build", + "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + *std_cmake_args + system "cmake", "--build", "build" + system "cmake", "--install", "build" + end + + test do + assert_match "gLabels", shell_output("#{bin}/glabels-batch-qt --version") + end +end +EOF +``` + +The `tag:` and `revision:` placeholders are filled at the first release (Task 12 Step 6). + +- [ ] **Step 3: Create `README.md` for the tap** + +```bash +cat > README.md <<'EOF' +# Seth's Homebrew tap + +Homebrew tap publishing macOS install for [sethLabels](https://git.sethpc.xyz/Seth/sethLabels). + +## Install + +``` +brew tap seth/tap https://git.sethpc.xyz/Seth/homebrew-tap.git +brew install seth/tap/glabels-qt +``` + +The explicit URL form is required because Homebrew defaults to GitHub for tap +names. When this repo is mirrored to GitHub at the public-flip, the URL becomes +implicit and the tap command shortens to `brew tap seth/tap`. + +## Formulae + +| Formula | Description | +|---------|-------------| +| `glabels-qt` | [gLabels label designer (Qt/C++)](https://git.sethpc.xyz/Seth/sethLabels) — Seth's packaging fork of glabels-qt | + +## How this works + +`brew install seth/tap/glabels-qt` clones the sethLabels git tag pinned in +`Formula/glabels-qt.rb`, builds Qt6 + glabels-qt from source, and installs to +`/opt/homebrew/`. First install takes ~5–10 minutes. Subsequent +`brew upgrade glabels-qt` runs are fast (only the version-bumped formula +re-builds). + +## Per-release maintenance + +Each sethLabels release is one commit on this repo: bump `tag:` and `revision:` +in `Formula/glabels-qt.rb`. No other edits expected. + +## Spec + +Design rationale lives in the sethLabels repo: +[`sethlabels-docs/specs/2026-04-29-packaging-design.md`](https://git.sethpc.xyz/Seth/sethLabels/src/branch/main/sethlabels-docs/specs/2026-04-29-packaging-design.md) §D2, §7. +EOF +``` + +- [ ] **Step 4: Initial commit on local tap repo** + +```bash +cd ~/bin/homebrew-tap +git add Formula/glabels-qt.rb README.md +git commit -m "chore: scaffold tap with glabels-qt formula" +``` + +- [ ] **Step 5: Create Gitea remote and push** + +Use the `gitea` CLI per global instructions: + +```bash +cd ~/bin/homebrew-tap +gitea create homebrew-tap --description "Homebrew tap publishing macOS install for sethLabels" +gitea remote homebrew-tap +gitea push +``` + +Expected: `gitea create` prints the new repo URL; `gitea remote` sets `origin`; `gitea push` pushes `main`. + +- [ ] **Step 6: Verify the tap can be browsed** + +```bash +curl -s https://git.sethpc.xyz/Seth/homebrew-tap/raw/branch/main/Formula/glabels-qt.rb | head -10 +``` + +Expected: prints the first 10 lines of the formula. + +- [ ] **Step 7: Return to sethLabels repo** + +```bash +cd ~/bin/sethLabels +``` + +The sethLabels repo has no commits in this task — the tap is a separate repo. The next task is the first end-to-end release of sethLabels itself. + +--- + +## Task 12: First end-to-end release dry run (operator checklist) + +**Files modified:** +- Modify: `~/bin/homebrew-tap/Formula/glabels-qt.rb` (Step 9 — replace placeholder tag and revision) +- Modify: `packaging/changelog.md` (Step 5 — add the seth1 entry) + +**What it does:** Walks the spec §6 release flow end-to-end. Produces real artifacts, tags the sethLabels repo, attaches artifacts to a Gitea release, bumps the brew formula, and (optionally) verifies T5 install on a clean Debian 13 VM. + +- [ ] **Step 1: Refresh tags + rebase** + +```bash +cd ~/bin/sethLabels +git fetch --all --tags +git rebase upstream/master +``` + +Expected: rebase is conflict-free (strict-zero invariant). If it conflicts, STOP — something has slipped past the guardrail. + +- [ ] **Step 2: Run guardrail explicitly** + +```bash +./scripts/check-no-upstream-edits.sh && echo CLEAN +``` + +Expected: `CLEAN`. + +- [ ] **Step 3: Build the .deb** + +```bash +./scripts/build-deb.sh +``` + +Expected: ends with `Artifact: build/deb/glabels-qt__amd64.deb`. Note the version string for use in later steps. + +- [ ] **Step 4: Build the AppImages** + +```bash +./scripts/build-appimages.sh +``` + +Expected: ends with two `Artifacts:` lines naming the GUI and batch AppImages. + +- [ ] **Step 5: Update `packaging/changelog.md`** + +Edit `packaging/changelog.md` to convert the `## (unreleased)` block to a real version block. Replace the existing `## (unreleased)` section with: + +```markdown +## + +- First end-to-end release of sethLabels packaging pipeline. +- `.deb` produced via CMake CPack with strict-zero `-D` overrides (no upstream edits). +- AppImages (GUI + batch) bundled via linuxdeploy + linuxdeploy-plugin-qt, pinned per F9. +- Brew tap initial publish at `git.sethpc.xyz/Seth/homebrew-tap`. + +## (unreleased) + +- (no changes since ) +``` + +Substitute `` with the value from Step 3 and `` with today's ISO date. + +```bash +git add packaging/changelog.md +git commit -m "docs: changelog for " +``` + +- [ ] **Step 6: Compute and tag** + +```bash +VERSION=$(./scripts/compute-version.sh) +echo "Tagging $VERSION" +git tag "$VERSION" +git push origin main --tags +``` + +Expected: tag pushes successfully. If the `seth` count is unexpectedly high, you forgot the `git fetch --all --tags` in Step 1 or there are leftover tags from local experiments. + +- [ ] **Step 7: Create Gitea release with artifacts attached** + +Use the Gitea API per `~/bin/GITEA_API.md`: + +```bash +DEB=$(ls build/deb/glabels-qt_*.deb | head -1) +GUI=$(ls sethlabels-gui-*.AppImage | head -1) +BATCH=$(ls sethlabels-batch-*.AppImage | head -1) + +# Read token from the standard location. +GITEA_TOKEN=$(cat ~/.config/gitea/token) +GITEA_BASE="https://git.sethpc.xyz/api/v1" + +# Create the release. +RELEASE_JSON=$(curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$VERSION\",\"name\":\"sethLabels $VERSION\",\"body\":\"See packaging/changelog.md for notes.\"}" \ + "$GITEA_BASE/repos/Seth/sethLabels/releases") +RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo "Created release ID $RELEASE_ID" + +# Attach all three artifacts. +for f in "$DEB" "$GUI" "$BATCH"; do + echo "Attaching $f" + curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -F "attachment=@${f}" \ + "$GITEA_BASE/repos/Seth/sethLabels/releases/$RELEASE_ID/assets?name=$(basename "$f")" \ + >/dev/null +done + +echo "Release URL: https://git.sethpc.xyz/Seth/sethLabels/releases/tag/$VERSION" +``` + +Expected: prints the release URL; opening it shows the three attachments. + +- [ ] **Step 8: Verify download URLs are public** + +```bash +curl -sI "https://git.sethpc.xyz/Seth/sethLabels/releases/download/$VERSION/$(basename "$DEB")" | head -1 +``` + +Expected: `HTTP/2 200` or `HTTP/2 302` (redirect to the asset). + +- [ ] **Step 9: Bump `homebrew-tap/Formula/glabels-qt.rb`** + +```bash +cd ~/bin/homebrew-tap +TAG="$VERSION" +REVISION=$(cd ~/bin/sethLabels && git rev-list -n 1 "$VERSION") +echo "Pinning tag=$TAG revision=$REVISION" + +# Replace placeholders. +sed -i "s|tag: \"PLACEHOLDER_FILLED_AT_FIRST_RELEASE\"|tag: \"$TAG\"|" Formula/glabels-qt.rb +sed -i "s|revision: \"PLACEHOLDER_FILLED_AT_FIRST_RELEASE\"|revision: \"$REVISION\"|" Formula/glabels-qt.rb + +# Verify the file is well-formed Ruby (parse-only check; doesn't run Homebrew). +ruby -c Formula/glabels-qt.rb + +git diff Formula/glabels-qt.rb +git add Formula/glabels-qt.rb +git commit -m "bump glabels-qt to $TAG" +git push origin main +``` + +Expected: `ruby -c` reports `Syntax OK`; `git push` succeeds. + +- [ ] **Step 10: Optional — T5 fresh-VM smoke test** + +Per spec §10, T5 is "install on a clean Debian 13 VM and run `glabels-qt --version`". This is the strongest signal that `dpkg-shlibdeps` produced a correct depends list. Recommended on every `seth1` release; skippable on `seth2`+ packaging-only fixes. + +If you have a clean Debian 13 VM available: + +``` +# On the clean VM: +wget https://git.sethpc.xyz/Seth/sethLabels/releases/download//glabels-qt__amd64.deb +sudo apt install -y ./glabels-qt__amd64.deb +glabels-qt --version +``` + +Expected: install succeeds; `--version` exits 0 and prints the version string. + +If `apt install` errors with unmet deps, the `dpkg-shlibdeps` calculation was wrong (spec §F8). Mitigation: override via `CPACK_DEBIAN_PACKAGE_DEPENDS` in `scripts/build-deb.sh`, rebuild, re-tag as `seth2`. + +- [ ] **Step 11: Update CLAUDE.md to reflect post-implementation state** + +```bash +cd ~/bin/sethLabels +``` + +Open `CLAUDE.md`, find the `## Current State` block, and replace its content with: + +```markdown +## Current State + +- **Phase:** post-first-release. Pipeline live. First tag: . Three artifacts attached to the Gitea release. Brew tap bumped to match. +- **Repo:** `git.sethpc.xyz/Seth/sethLabels` (default branch `main`). Tap: `git.sethpc.xyz/Seth/homebrew-tap`. Upstream: `j-evins/glabels-qt` (`upstream` remote). +- **Deploy targets live:** Debian-family Linux (`.deb` + AppImage) and macOS via Homebrew tap. +- **Next release:** rebase, build, tag, attach, bump tap. See `scripts/README.md` and spec §6. +``` + +```bash +git add CLAUDE.md +git commit -m "docs: refresh CLAUDE.md to post-first-release phase" +git push origin main +``` + +- [ ] **Step 12: Write a session handoff** + +Per Seth's global persistence convention, create a handoff document capturing the session's outcome: + +```bash +# Use the session-handoff skill to create the handoff document with proper structure. +# (See ~/.claude/CLAUDE.md → Persistence Partition → Session close). +``` + +The handoff filename pattern is `.claude/handoffs/YYYY-MM-DD-HHMMSS-first-release.md`. + +--- + +## Self-review checklist + +After all 12 tasks complete, the implementer should verify: + +- [ ] **Spec coverage:** every section of `sethlabels-docs/specs/2026-04-29-packaging-design.md` maps to a task — §2 (invariants enforced by Task 3); §3 (decisions reflected throughout); §4 (file structure produced); §5.1–5.5 (Tasks 4–7 in order); §6 (Task 12); §7 (Task 11); §F1–F9 (each guarded somewhere — F1=Task 3, F2=Task 8 inline T4, F3=Task 4, F4=Task 11, F5=Task 2, F6=Task 9, F7=Task 12 step 1 rebase verification, F8=Task 12 step 10 T5, F9=Task 5 explicit pinning); §10 (T1–T4 inline; T5 in Task 12 step 10). +- [ ] **No upstream files modified:** `git diff --name-only upstream/master..HEAD` shows only allowlisted paths. +- [ ] **All bats tests pass:** `./tests-impl/run-all.sh` reports green. +- [ ] **The .deb installs cleanly** on a fresh Debian 13 VM and `glabels-qt --version` exits 0. +- [ ] **Both AppImages run** under `APPIMAGE_EXTRACT_AND_RUN=1` (no FUSE dependency). +- [ ] **Brew formula parses:** `ruby -c Formula/glabels-qt.rb` reports `Syntax OK`. (Live `brew install` test on a Mac is recommended but not gate-blocking.) +- [ ] **Release flow steps 1–9 of spec §6 ran cleanly** with no manual deviations.