59 KiB
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:
- Spec §5.2 omits
-D CPACK_PACKAGE_NAME=glabels-qt. UpstreamCMakeLists.txt:86setsCPACK_PACKAGE_NAME=glabels— without the override the.debwould be namedglabels_${VERSION}_amd64.deb, contradicting decision D6. Task 7 adds the override. - Spec §F9 requires pinned linuxdeploy versions, no specific tag named. Task 5 has the implementer query GitHub for the latest release tag of
linuxdeploy/linuxdeployandlinuxdeploy/linuxdeploy-plugin-qt, then hardcode those tags inscripts/lib/linuxdeploy.sh. - 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.gitignoreis 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
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
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
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
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.
ls -la scripts/ scripts/lib/ packaging/ tests-impl/
Expected: each dir exists; tests-impl/run-all.sh is present and executable.
- Step 7: Commit
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
batsis installed
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:
#!/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
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:
#!/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}"
chmod +x scripts/compute-version.sh
- Step 5: Run tests; confirm they pass
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
./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
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:
#!/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
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:
#!/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
chmod +x scripts/check-no-upstream-edits.sh
- Step 4: Run tests; confirm they pass
bats tests-impl/test-check-no-upstream-edits.bats
Expected: all 6 tests pass.
- Step 5: Run the guardrail against the real repo
./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
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:
#!/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
bats tests-impl/test-deps-debian.bats
Expected: all fail.
- Step 3: Write the script to pass
Create scripts/lib/deps-debian.sh:
#!/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)."
chmod +x scripts/lib/deps-debian.sh
- Step 4: Run tests; confirm they pass
bats tests-impl/test-deps-debian.bats
Expected: all 8 tests pass.
- Step 5: Run the script directly
./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
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:
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):
#!/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
chmod +x scripts/lib/linuxdeploy.sh
- Step 3: Verify the cache dir is gitignored
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)
./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
"$(./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:
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)
./scripts/lib/linuxdeploy.sh
Expected: instant exit, no Downloading ... messages.
- Step 7: Commit
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
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
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
cat > packaging/changelog.md <<'EOF'
# sethLabels packaging changelog
Per-release packaging notes. Each entry covers what changed in the *packaging*,
not what changed upstream. For application-level changes, see the upstream
`docs/CHANGELOG.md` and `git log upstream/master`.
## Format
—
- bullet describing what changed in this packaging release
- ...
`<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
( 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
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:
#!/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"
chmod +x scripts/build-deb.sh
- Step 2: Run the build
./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
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)
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
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:
#!/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"
chmod +x scripts/build-appimages.sh
- Step 2: Run the build
./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
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
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
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
cat > scripts/README.md <<'EOF'
# sethLabels build scripts
Canonical recipe for building sethLabels artifacts. CI YAML at the public-flip
will call these scripts unmodified — no logic moves into YAML (spec §I3).
## Quick reference
./scripts/lib/deps-debian.sh # check / install build deps ./scripts/check-no-upstream-edits.sh # enforce strict-zero (I1) ./scripts/compute-version.sh # emit -seth ./scripts/build-deb.sh # → build/deb/glabels-qt__amd64.deb ./scripts/build-appimages.sh # → sethlabels-{gui,batch}--x86_64.AppImage
## Prerequisites
Debian 13 (Trixie) or Ubuntu 24.04 LTS. Run:
./scripts/lib/deps-debian.sh
If anything is missing, the script prints the exact `sudo apt install ...`
command to run.
`bats` (bash test framework) is in the dep list — it's required for the
implementation tests under `tests-impl/`.
`linuxdeploy` and `linuxdeploy-plugin-qt` are NOT apt-installable; they're
downloaded automatically by `scripts/lib/linuxdeploy.sh` to `scripts/.cache/`
on first AppImage build.
## Versioning
`<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
head -40 scripts/README.md
Expected: clean Markdown, no obvious typos.
- Step 3: Commit
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
cat > README.sethlabels.md <<'EOF'
# sethLabels
> Deployment fork of [glabels-qt](https://github.com/j-evins/glabels-qt) — Qt6
> label designer / printer, packaged for Debian-family Linux and macOS.
This is **not** a code fork. The upstream application is unchanged; sethLabels
exists solely to publish installable binary artifacts that upstream explicitly
does not provide ("Currently there are no self-hosted binary snapshot releases
available… I encourage you to try building the code yourself" — upstream README).
For the application itself — what it does, screenshots, full feature list — see
the upstream [`README.md`](README.md).
## Install
### Debian / Ubuntu (`.deb`)
Download the latest `.deb` from the [releases page](https://git.sethpc.xyz/Seth/sethLabels/releases),
then:
sudo apt install ./glabels-qt__amd64.deb glabels-qt --version
### Any Linux (AppImage)
Download `sethlabels-gui-<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--x86_64.AppImage ./sethlabels-gui--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
./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
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
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
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.mdfor the tap
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
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:
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
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
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
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
./scripts/check-no-upstream-edits.sh && echo CLEAN
Expected: CLEAN.
- Step 3: Build the .deb
./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
./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:
## <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.
git add packaging/changelog.md
git commit -m "docs: changelog for <VERSION>"
- Step 6: Compute and tag
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:
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
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
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
cd ~/bin/sethLabels
Open CLAUDE.md, find the ## Current State block, and replace its content with:
## 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.
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:
# 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.mdmaps 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..HEADshows only allowlisted paths. - All bats tests pass:
./tests-impl/run-all.shreports green. - The .deb installs cleanly on a fresh Debian 13 VM and
glabels-qt --versionexits 0. - Both AppImages run under
APPIMAGE_EXTRACT_AND_RUN=1(no FUSE dependency). - Brew formula parses:
ruby -c Formula/glabels-qt.rbreportsSyntax OK. (Livebrew installtest on a Mac is recommended but not gate-blocking.) - Release flow steps 1–9 of spec §6 ran cleanly with no manual deviations.