Files
sethLabels/sethlabels-docs/plans/2026-04-29-packaging-implementation.md

1816 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (T1T4 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 `<upstream-tag>-seth<N>` 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 `<upstream-tag>-seth<N>` to stdout, where `<upstream-tag>` = `git describe --tags --abbrev=0 upstream/master` and `<N>` = count of existing `<upstream-tag>-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 '<upstream-tag>-seth<N>' 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 "<upstream-tag>-seth<N>" 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 <N> 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<N>` 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 `<TAG>` 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="<REPLACE_WITH_DISCOVERED_TAG>"
LINUXDEPLOY_PLUGIN_QT_TAG="<REPLACE_WITH_DISCOVERED_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_<KEY>="$VALUE".
#
# Spec: sethlabels-docs/specs/2026-04-29-packaging-design.md §5.2
MAINTAINER="Seth Freiberg <seth@sethfreiberg.com>"
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
```
## <version> — <ISO date>
- bullet describing what changed in this packaging release
- ...
```
`<version>` is `<upstream-tag>-seth<N>` (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 ~5080 MB (bundles Qt6), batch is ~2040 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 <upstream-tag>-seth<N>
./scripts/build-deb.sh # → build/deb/glabels-qt_<VERSION>_amd64.deb
./scripts/build-appimages.sh # → sethlabels-{gui,batch}-<VERSION>-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
`<upstream-tag>-seth<N>` (e.g., `3.99-master618-seth1`). The `<N>` counter is
computed from existing git tags matching `<upstream-tag>-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 (T1T4)
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_<VERSION>_amd64.deb
glabels-qt --version
```
### Any Linux (AppImage)
Download `sethlabels-gui-<VERSION>-x86_64.AppImage` from the [releases page](https://git.sethpc.xyz/Seth/sethLabels/releases),
make it executable, and run it:
```
chmod +x sethlabels-gui-<VERSION>-x86_64.AppImage
./sethlabels-gui-<VERSION>-x86_64.AppImage
```
A separate `sethlabels-batch-<VERSION>-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 (~510 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 `<upstream-tag>-seth<N>` 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 ~510 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_<VERSION>_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
## <VERSION> — <YYYY-MM-DD>
- 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 <VERSION>)
```
Substitute `<VERSION>` with the value from Step 3 and `<YYYY-MM-DD>` with today's ISO date.
```bash
git add packaging/changelog.md
git commit -m "docs: changelog for <VERSION>"
```
- [ ] **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<N>` 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/<VERSION>/glabels-qt_<VERSION>_amd64.deb
sudo apt install -y ./glabels-qt_<VERSION>_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: <VERSION>. 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.15.5 (Tasks 47 in order); §6 (Task 12); §7 (Task 11); §F1F9 (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 (T1T4 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 19 of spec §6 ran cleanly** with no manual deviations.