# 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 `<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 ~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 <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 (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_<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 (~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 `<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 ~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_<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.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.
