From 6c7368957b9539eab194cfc5e9423edacf8b370c Mon Sep 17 00:00:00 2001
From: Kaj Kowalski
Date: Thu, 4 Jun 2026 05:00:07 +0000
Subject: [PATCH] feat(debian): publish .deb packages + signed apt repo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a Debian/apt distribution channel alongside crates.io, npm, and AUR.
- runner-run .debs for amd64/arm64/armhf, repackaged from the prebuilt
release tarballs (glibc, dynamically linked) like the AUR `-bin`/npm
channels — same binaries, byte-for-byte. Ship the runner+run binaries,
bash/zsh/fish completions in the canonical autoload dirs, the pwsh
script, copyright, and a native changelog. A lintian override documents
the one false positive (embedded-library libyaml: pure-Rust yaml-rust2,
not C libyaml), so packages lint clean of errors.
- Signed apt repository served at https://apt.runner.kjanat.dev (GitHub
Pages, custom domain): apt-get install runner-run. pool/ is rebuilt
from the published branch each release so old versions stay installable;
dists/ is regenerated and GPG-signed (InRelease + detached Release.gpg).
- debian-release.yml: build-deb attaches the .debs to the release (no
secrets, always runs); publish-apt assembles+signs+pushes the repo to
gh-pages, gated behind an `apt` Environment and inert until
APT_GPG_PRIVATE_KEY is set.
- Docs: README + site install entries, debian/README.md (channel + the
one-time maintainer setup), CHANGELOG.
Validated locally: dpkg-deb build, lintian --fail-on error (clean),
install + run, completion path rewrite, and an isolated apt-get update
against the signed repo (InRelease + Release.gpg verify, apt-cache
resolves runner-run).
https://claude.ai/code/session_018uA1KjrCHk6PPK2t6zaYYH
---
.github/scripts/build/deb-build.sh | 219 ++++++++++++++++++++++++++
.github/scripts/build/deb-download.sh | 31 ++++
.github/scripts/publish/apt-repo.sh | 161 +++++++++++++++++++
.github/workflows/debian-release.yml | 162 +++++++++++++++++++
.gitignore | 1 +
CHANGELOG.md | 30 ++++
README.md | 15 ++
debian/README.md | 110 +++++++++++++
debian/control.in | 22 +++
debian/copyright | 27 ++++
debian/lintian-overrides | 6 +
site/src/index.html | 14 ++
12 files changed, 798 insertions(+)
create mode 100755 .github/scripts/build/deb-build.sh
create mode 100755 .github/scripts/build/deb-download.sh
create mode 100755 .github/scripts/publish/apt-repo.sh
create mode 100644 .github/workflows/debian-release.yml
create mode 100644 debian/README.md
create mode 100644 debian/control.in
create mode 100644 debian/copyright
create mode 100644 debian/lintian-overrides
diff --git a/.github/scripts/build/deb-build.sh b/.github/scripts/build/deb-build.sh
new file mode 100755
index 0000000..9bec0e6
--- /dev/null
+++ b/.github/scripts/build/deb-build.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+#
+# Build the Debian .deb packages for runner-run from the prebuilt release
+# tarballs (the same `runner-v-.tar.gz` assets the GitHub
+# release publishes). No compilation happens here: we repackage the exact
+# released binaries byte-for-byte, the way the AUR `-bin` and npm channels do.
+#
+# Debian arch <- Rust triple <- glibc, dynamically linked
+# amd64 x86_64-unknown-linux-gnu
+# arm64 aarch64-unknown-linux-gnu
+# armhf armv7-unknown-linux-gnueabihf
+#
+# Shell completions are arch-independent (they only embed the installed
+# /usr/bin path), so they are generated once from the amd64 binary and shipped
+# in all three packages. This means the script must run on an amd64 host (it
+# does: release.yml routes it to ubuntu-latest, matching local dev boxes).
+#
+# Env:
+# VERSION upstream version, no leading v (e.g. 0.12.0). Required.
+# DEB_DOWNLOAD_DIR dir holding runner-v$VERSION-.tar.gz + .sha256.
+# Default: debian/.work/download
+# DEB_OUT_DIR output dir for the finished .deb files.
+# Default: debian/.work/out
+# REPO_ROOT repo checkout root (control.in, copyright, README.md).
+# Default: `git rev-parse --show-toplevel`, else $PWD.
+# DEB_SKIP_CHECKSUM set to 1 to skip .sha256 verification (local dev only).
+set -euo pipefail
+export LC_ALL=C
+
+VERSION="${VERSION:?VERSION required (upstream version without leading v)}"
+DEB_DOWNLOAD_DIR="${DEB_DOWNLOAD_DIR:-debian/.work/download}"
+DEB_OUT_DIR="${DEB_OUT_DIR:-debian/.work/out}"
+REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
+DEB_SKIP_CHECKSUM="${DEB_SKIP_CHECKSUM:-0}"
+
+# Reject anything that isn't strict semver before it reaches the `sed`
+# substitutions below — keeps the alphabet to [0-9A-Za-z.-] so the rewrite of
+# @VERSION@ into the control file is byte-for-byte literal (same guard as
+# aur-prepare.sh). A leading `v`, `&`, `/`, `\`, or a newline is refused here.
+if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
+ echo "error: VERSION '${VERSION}' is not strict semver (X.Y.Z or X.Y.Z-prerelease)" >&2
+ exit 1
+fi
+
+# Debian version ordering: a semver prerelease (`-rc.1`) must sort *before* the
+# final release, which Debian expresses with `~`, not `-`. 0.12.0 -> 0.12.0;
+# 0.13.0-rc.1 -> 0.13.0~rc.1.
+DEB_VERSION="${VERSION//-/\~}"
+
+# deb_arch:rust_triple, amd64 first so its extract is ready for completions.
+ARCHES=(
+ "amd64:x86_64-unknown-linux-gnu"
+ "arm64:aarch64-unknown-linux-gnu"
+ "armhf:armv7-unknown-linux-gnueabihf"
+)
+
+WORK="$(mktemp -d)"
+trap 'rm -rf "${WORK}"' EXIT
+COMPL_DIR="${WORK}/completions"
+# One RFC 5322 timestamp shared by every package's changelog so the three
+# builds are reproducible relative to each other.
+CHANGELOG_DATE="$(date -u -R)"
+
+# verify_and_extract : checksum the release tarball
+# against its published .sha256 companion, then extract its flat contents
+# (runner, run, README.md, LICENSE) into .
+verify_and_extract() {
+ local triple="$1" dest="$2"
+ local tarball="runner-v${VERSION}-${triple}.tar.gz"
+ local sumfile="runner-v${VERSION}-${triple}.sha256"
+
+ if [[ ! -f "${DEB_DOWNLOAD_DIR}/${tarball}" ]]; then
+ echo "error: missing ${DEB_DOWNLOAD_DIR}/${tarball}" >&2
+ exit 1
+ fi
+ if [[ "${DEB_SKIP_CHECKSUM}" != "1" ]]; then
+ if [[ ! -f "${DEB_DOWNLOAD_DIR}/${sumfile}" ]]; then
+ echo "error: missing checksum ${DEB_DOWNLOAD_DIR}/${sumfile} (set DEB_SKIP_CHECKSUM=1 to bypass)" >&2
+ exit 1
+ fi
+ (cd "${DEB_DOWNLOAD_DIR}" && sha256sum -c --status "${sumfile}")
+ fi
+
+ mkdir -p "${dest}"
+ tar -xzf "${DEB_DOWNLOAD_DIR}/${tarball}" -C "${dest}"
+ local b
+ for b in runner run; do
+ if [[ ! -f "${dest}/${b}" ]]; then
+ echo "error: ${tarball} did not contain '${b}'" >&2
+ exit 1
+ fi
+ done
+}
+
+# gen_completions : emit per-shell completion
+# files into $COMPL_DIR with the current_exe()-baked paths rewritten to the
+# installed /usr/bin locations. `runner completions ` is the only
+# generator and emits a single stream covering BOTH `runner` and `run`; bash
+# and zsh are split into one autoload file per command. (Identical logic to
+# aur/runner-run-bin/PKGBUILD.)
+gen_completions() {
+ local bindir="$1"
+ local runner="${bindir}/runner" run="${bindir}/run"
+ mkdir -p "${COMPL_DIR}"
+ "${runner}" completions bash >"${COMPL_DIR}/bash.combined"
+ "${runner}" completions zsh >"${COMPL_DIR}/zsh.combined"
+ "${runner}" completions fish >"${COMPL_DIR}/fish.combined"
+ "${runner}" completions pwsh >"${COMPL_DIR}/runner.ps1"
+ # Longer match first — `/run` is a prefix of `/runner`.
+ sed -i \
+ -e "s|${runner}|/usr/bin/runner|g" \
+ -e "s|${run}|/usr/bin/run|g" \
+ "${COMPL_DIR}/bash.combined" "${COMPL_DIR}/zsh.combined" \
+ "${COMPL_DIR}/fish.combined" "${COMPL_DIR}/runner.ps1"
+ awk -v r="${COMPL_DIR}/runner.bash" -v n="${COMPL_DIR}/run.bash" \
+ '/^_clap_complete_run\(\) \{$/ {o=n} {print > (o?o:r)}' "${COMPL_DIR}/bash.combined"
+ awk -v r="${COMPL_DIR}/_runner" -v n="${COMPL_DIR}/_run" \
+ '/^#compdef run$/ {o=n} {print > (o?o:r)}' "${COMPL_DIR}/zsh.combined"
+}
+
+# gen_changelog : write a Debian-format changelog, gzip -9n (no name or
+# timestamp header) so the data.tar member is reproducible and lintian-clean.
+gen_changelog() {
+ local out="$1"
+ mkdir -p "$(dirname "${out}")"
+ {
+ echo "runner-run (${DEB_VERSION}) stable; urgency=medium"
+ echo
+ echo " * Release ${VERSION}. Upstream changelog:"
+ echo " https://github.com/kjanat/runner/blob/v${VERSION}/CHANGELOG.md"
+ echo
+ echo " -- Kaj Kowalski ${CHANGELOG_DATE}"
+ } | gzip -9n >"${out}"
+}
+
+# build_one : assemble the package tree and emit the .deb.
+build_one() {
+ local deb_arch="$1" bindir="$2"
+ local pkgroot="${WORK}/pkg-${deb_arch}"
+ rm -rf "${pkgroot}"
+
+ # Binaries (already stripped by the release profile; root:root set at build).
+ install -Dm0755 "${bindir}/runner" "${pkgroot}/usr/bin/runner"
+ install -Dm0755 "${bindir}/run" "${pkgroot}/usr/bin/run"
+
+ # Docs. A native package (no Debian revision) takes changelog.gz; copyright
+ # is mandatory under Debian policy.
+ install -Dm0644 "${REPO_ROOT}/debian/copyright" "${pkgroot}/usr/share/doc/runner-run/copyright"
+ install -Dm0644 "${REPO_ROOT}/README.md" "${pkgroot}/usr/share/doc/runner-run/README.md"
+ gen_changelog "${pkgroot}/usr/share/doc/runner-run/changelog.gz"
+
+ # Document the one lintian false positive (pure-Rust yaml-rust2, not C
+ # libyaml) so the package lints clean wherever it is checked.
+ install -Dm0644 "${REPO_ROOT}/debian/lintian-overrides" "${pkgroot}/usr/share/lintian/overrides/runner-run"
+
+ # Shell completions, auto-loaded from the canonical system dirs.
+ install -Dm0644 "${COMPL_DIR}/runner.bash" "${pkgroot}/usr/share/bash-completion/completions/runner"
+ install -Dm0644 "${COMPL_DIR}/run.bash" "${pkgroot}/usr/share/bash-completion/completions/run"
+ install -Dm0644 "${COMPL_DIR}/_runner" "${pkgroot}/usr/share/zsh/site-functions/_runner"
+ install -Dm0644 "${COMPL_DIR}/_run" "${pkgroot}/usr/share/zsh/site-functions/_run"
+ # Fish autoloads by command basename, so ship the (identical) combined
+ # stream under both names — each command's first works in a fresh
+ # shell regardless of session order.
+ install -Dm0644 "${COMPL_DIR}/fish.combined" "${pkgroot}/usr/share/fish/vendor_completions.d/runner.fish"
+ install -Dm0644 "${COMPL_DIR}/fish.combined" "${pkgroot}/usr/share/fish/vendor_completions.d/run.fish"
+ # PowerShell has no system autoload dir on Linux — dot-source from $PROFILE.
+ install -Dm0644 "${COMPL_DIR}/runner.ps1" "${pkgroot}/usr/share/runner/runner.ps1"
+
+ # DEBIAN/control from the checked-in template.
+ local installed_size
+ installed_size="$(du -k -s "${pkgroot}/usr" | cut -f1)"
+ mkdir -p "${pkgroot}/DEBIAN"
+ sed \
+ -e "s/@VERSION@/${DEB_VERSION}/" \
+ -e "s/@ARCH@/${deb_arch}/" \
+ -e "s/@INSTALLED_SIZE@/${installed_size}/" \
+ "${REPO_ROOT}/debian/control.in" >"${pkgroot}/DEBIAN/control"
+
+ # md5sums over the payload (paths relative to the package root, DEBIAN/
+ # excluded), sorted for a deterministic control.tar member.
+ (cd "${pkgroot}" && find usr -type f -print0 | sort -z | xargs -0 md5sum >DEBIAN/md5sums)
+
+ # xz keeps the archive installable on older apt/dpkg (zstd debs need a very
+ # recent toolchain). --root-owner-group stamps root:root without fakeroot.
+ mkdir -p "${DEB_OUT_DIR}"
+ local out="${DEB_OUT_DIR}/runner-run_${DEB_VERSION}_${deb_arch}.deb"
+ dpkg-deb -Zxz --root-owner-group --build "${pkgroot}" "${out}" >/dev/null
+ echo "built ${out}"
+}
+
+main() {
+ if [[ ! -f "${REPO_ROOT}/debian/control.in" ]]; then
+ echo "error: ${REPO_ROOT}/debian/control.in not found (wrong REPO_ROOT?)" >&2
+ exit 1
+ fi
+ mkdir -p "${DEB_OUT_DIR}"
+
+ # Extract every arch up front (verifies checksums), then generate the
+ # shared completions from the amd64 binaries.
+ local pair deb_arch triple
+ declare -A extracted=()
+ for pair in "${ARCHES[@]}"; do
+ deb_arch="${pair%%:*}"
+ triple="${pair#*:}"
+ verify_and_extract "${triple}" "${WORK}/extract-${deb_arch}"
+ extracted["${deb_arch}"]="${WORK}/extract-${deb_arch}"
+ done
+ gen_completions "${extracted[amd64]}"
+
+ for pair in "${ARCHES[@]}"; do
+ deb_arch="${pair%%:*}"
+ build_one "${deb_arch}" "${extracted[${deb_arch}]}"
+ done
+
+ echo "--- packages in ${DEB_OUT_DIR} ---"
+ ls -la "${DEB_OUT_DIR}"/*.deb
+}
+
+main "$@"
diff --git a/.github/scripts/build/deb-download.sh b/.github/scripts/build/deb-download.sh
new file mode 100755
index 0000000..86581c1
--- /dev/null
+++ b/.github/scripts/build/deb-download.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+#
+# Download the prebuilt linux-gnu release tarballs (+ their .sha256 companions)
+# that the Debian .debs are repackaged from, for the three arches we ship.
+# Mirrors download-release-archives.sh but fetches only the gnu triples Debian
+# needs instead of the whole release.
+set -euo pipefail
+
+RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}"
+GITHUB_REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY required}"
+DEB_DOWNLOAD_DIR="${DEB_DOWNLOAD_DIR:-debian/.work/download}"
+
+version="${RELEASE_TAG#v}"
+triples=(
+ x86_64-unknown-linux-gnu
+ aarch64-unknown-linux-gnu
+ armv7-unknown-linux-gnueabihf
+)
+
+# Scrub before fetch: a stale tarball from a previous tag would pass the
+# checksum walk in deb-build.sh yet be the wrong version.
+rm -rf "${DEB_DOWNLOAD_DIR}"
+mkdir -p "${DEB_DOWNLOAD_DIR}"
+for triple in "${triples[@]}"; do
+ gh release download "${RELEASE_TAG}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --pattern "runner-v${version}-${triple}.tar.gz" \
+ --pattern "runner-v${version}-${triple}.sha256" \
+ --dir "${DEB_DOWNLOAD_DIR}"
+done
+ls -la "${DEB_DOWNLOAD_DIR}"
diff --git a/.github/scripts/publish/apt-repo.sh b/.github/scripts/publish/apt-repo.sh
new file mode 100755
index 0000000..8e2f52d
--- /dev/null
+++ b/.github/scripts/publish/apt-repo.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+#
+# Assemble (and optionally GPG-sign) the static apt repository served at
+# https://$APT_DOMAIN, the index for `apt-get install runner-run`.
+#
+# Idempotent and stateless: the .deb files in $DEB_DIR are copied into pool/,
+# then the whole dists/ index is regenerated from whatever pool/ now holds.
+# Point $APT_REPO_DIR at a checkout of the published branch and pool/ keeps
+# every past release, so old versions stay installable (apt install
+# runner-run=). dpkg-scanpackages -m lists all versions, not just newest.
+#
+# Layout produced (under $APT_REPO_DIR), matching `deb https://$APT_DOMAIN
+# $APT_SUITE $APT_COMPONENT`:
+# pool//r/runner-run/runner-run__.deb
+# dists//Release{,.gpg}, InRelease
+# dists///binary-/{Packages,Packages.gz,Packages.xz,Release}
+# runner-run.gpg / runner-run.asc (public key, for `signed-by=`)
+# CNAME, .nojekyll, index.html
+#
+# Env:
+# DEB_DIR dir containing the *.deb to add. Required.
+# APT_REPO_DIR repo working tree (pre-seeded from the published branch).
+# Required.
+# APT_DOMAIN custom domain, e.g. apt.runner.kjanat.dev. Required.
+# APT_SUITE distribution. Default: stable
+# APT_COMPONENT component. Default: main
+# APT_ARCHES space-separated dpkg arches. Default: "amd64 arm64 armhf"
+# APT_ORIGIN Release Origin/Label. Default: runner-run
+# APT_SIGN "true" to sign Release. Default: false
+# GPG_KEY_ID signing key id/fingerprint (required when APT_SIGN=true).
+# GPG_PASSPHRASE optional passphrase for the signing key (loopback pinentry).
+set -euo pipefail
+export LC_ALL=C
+
+DEB_DIR="${DEB_DIR:?DEB_DIR required (dir containing *.deb)}"
+APT_REPO_DIR="${APT_REPO_DIR:?APT_REPO_DIR required (repo working tree)}"
+APT_DOMAIN="${APT_DOMAIN:?APT_DOMAIN required (e.g. apt.runner.kjanat.dev)}"
+APT_SUITE="${APT_SUITE:-stable}"
+APT_COMPONENT="${APT_COMPONENT:-main}"
+APT_ARCHES="${APT_ARCHES:-amd64 arm64 armhf}"
+APT_ORIGIN="${APT_ORIGIN:-runner-run}"
+APT_SIGN="${APT_SIGN:-false}"
+GPG_KEY_ID="${GPG_KEY_ID:-}"
+GPG_PASSPHRASE="${GPG_PASSPHRASE:-}"
+
+for tool in dpkg-scanpackages apt-ftparchive; do
+ if ! command -v "${tool}" >/dev/null 2>&1; then
+ echo "error: required tool '${tool}' not found (install dpkg-dev / apt-utils)" >&2
+ exit 1
+ fi
+done
+
+shopt -s nullglob
+debs=("${DEB_DIR}"/*.deb)
+shopt -u nullglob
+if [[ "${#debs[@]}" -eq 0 ]]; then
+ echo "error: no .deb files in ${DEB_DIR}" >&2
+ exit 1
+fi
+
+pool="pool/${APT_COMPONENT}/r/runner-run"
+dist="dists/${APT_SUITE}"
+
+# dpkg-scanpackages override: pins Priority/Section in the index and silences
+# its "missing from override file" warning. Absolute path so it survives the cd.
+override="$(mktemp)"
+trap 'rm -f "${override}"' EXIT
+printf 'runner-run optional devel\n' >"${override}"
+
+mkdir -p "${APT_REPO_DIR}/${pool}"
+cp -f "${debs[@]}" "${APT_REPO_DIR}/${pool}/"
+
+cd "${APT_REPO_DIR}"
+
+# Rebuild the index from scratch so a removed/replaced pool file can never
+# leave a dangling entry behind.
+rm -rf "${dist}"
+
+for arch in ${APT_ARCHES}; do
+ bindir="${dist}/${APT_COMPONENT}/binary-${arch}"
+ mkdir -p "${bindir}"
+ # Run from the repo root so Filename: is `pool/...` (apt resolves it
+ # relative to the repo root, i.e. the dir holding dists/ and pool/).
+ dpkg-scanpackages -m -a "${arch}" "pool" "${override}" >"${bindir}/Packages"
+ gzip -9nk -f "${bindir}/Packages"
+ xz -9k -f "${bindir}/Packages"
+ cat >"${bindir}/Release" <<-EOF
+ Archive: ${APT_SUITE}
+ Suite: ${APT_SUITE}
+ Component: ${APT_COMPONENT}
+ Origin: ${APT_ORIGIN}
+ Label: ${APT_ORIGIN}
+ Architecture: ${arch}
+ EOF
+done
+
+# Top-level Release with checksums over every file under dists/.
+apt-ftparchive \
+ -o "APT::FTPArchive::Release::Origin=${APT_ORIGIN}" \
+ -o "APT::FTPArchive::Release::Label=${APT_ORIGIN}" \
+ -o "APT::FTPArchive::Release::Suite=${APT_SUITE}" \
+ -o "APT::FTPArchive::Release::Codename=${APT_SUITE}" \
+ -o "APT::FTPArchive::Release::Components=${APT_COMPONENT}" \
+ -o "APT::FTPArchive::Release::Architectures=${APT_ARCHES}" \
+ -o "APT::FTPArchive::Release::Description=runner-run apt repository" \
+ release "${dist}" >"${dist}/Release"
+
+if [[ "${APT_SIGN}" == "true" ]]; then
+ if [[ -z "${GPG_KEY_ID}" ]]; then
+ echo "error: APT_SIGN=true but GPG_KEY_ID is empty" >&2
+ exit 1
+ fi
+ gpg_opts=(--batch --yes --armor --local-user "${GPG_KEY_ID}")
+ if [[ -n "${GPG_PASSPHRASE}" ]]; then
+ gpg_opts+=(--pinentry-mode loopback --passphrase "${GPG_PASSPHRASE}")
+ fi
+ # Inline-signed (InRelease) + detached (Release.gpg) so both old and new
+ # apt acquisition paths verify.
+ gpg "${gpg_opts[@]}" --clearsign --output "${dist}/InRelease" "${dist}/Release"
+ gpg "${gpg_opts[@]}" --detach-sign --output "${dist}/Release.gpg" "${dist}/Release"
+ # Publish the public key both armored (human-inspectable) and dearmored
+ # (the form `signed-by=/etc/apt/keyrings/runner-run.gpg` expects).
+ gpg --armor --export "${GPG_KEY_ID}" >"runner-run.asc"
+ gpg --export "${GPG_KEY_ID}" >"runner-run.gpg"
+else
+ echo "note: APT_SIGN!=true — repository left UNSIGNED (apt will refuse it)" >&2
+fi
+
+# GitHub Pages plumbing: custom domain + skip Jekyll (which would drop files
+# whose names start with '_' or '.').
+printf '%s\n' "${APT_DOMAIN}" >CNAME
+: >.nojekyll
+
+cat >index.html <
+
+
+
+
+runner-run apt repository
+
+
+
+
runner-run apt repository
+
runner is a universal project task runner. Install it on Debian/Ubuntu and derivatives:
+
+
+EOF
+
+echo "--- apt repository assembled at ${APT_REPO_DIR} ---"
+find "${dist}" -type f | sort
+echo "pool:"
+find "pool" -type f | sort
diff --git a/.github/workflows/debian-release.yml b/.github/workflows/debian-release.yml
new file mode 100644
index 0000000..3db7300
--- /dev/null
+++ b/.github/workflows/debian-release.yml
@@ -0,0 +1,162 @@
+name: debian-release
+run-name: >-
+ ${{ format('debian/apt publish {0}',
+ case(github.event_name == 'release', github.event.release.tag_name,
+ inputs.tag)) }}
+
+on:
+ release: { types: [published] }
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag to publish (e.g. v0.12.0)
+ required: true
+ type: string
+ dry-run:
+ description: Build + sign but do not upload to the release or push the apt repo
+ required: true
+ default: false
+ type: boolean
+
+permissions: { contents: read }
+
+env:
+ RELEASE_TAG: ${{ case(github.event_name == 'release', github.event.release.tag_name, inputs.tag) }}
+ DRY_RUN: ${{ inputs.dry-run || 'false' }}
+
+jobs:
+ # Repackage the prebuilt release binaries (gnu) into .debs and attach them to
+ # the GitHub release. Needs no secrets — always runs on a release.
+ build-deb:
+ name: build .deb packages
+ runs-on: ubuntu-latest
+ permissions: { contents: write }
+ env: { GH_TOKEN: "${{ github.token }}" }
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with: { persist-credentials: false }
+
+ - name: install lintian
+ run: sudo apt-get update && sudo apt-get install -y lintian
+
+ - name: download release tarballs
+ run: bash "${GITHUB_WORKSPACE}/.github/scripts/build/deb-download.sh"
+
+ - name: build .deb packages
+ run: |
+ set -euo pipefail
+ # deb-build.sh wants the version without the leading v.
+ VERSION="${RELEASE_TAG#v}" bash "${GITHUB_WORKSPACE}/.github/scripts/build/deb-build.sh"
+
+ - name: lint (fail on errors; overrides + warnings allowed)
+ run: |
+ set -euo pipefail
+ for deb in debian/.work/out/*.deb; do
+ echo "::group::lintian ${deb##*/}"
+ lintian --fail-on error --tag-display-limit 0 "${deb}"
+ echo "::endgroup::"
+ done
+
+ - name: upload .deb assets to the release
+ if: env.DRY_RUN != 'true'
+ run: gh release upload "${RELEASE_TAG}" debian/.work/out/*.deb --repo "${GITHUB_REPOSITORY}" --clobber
+
+ - name: upload deb-packages artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: deb-packages
+ path: debian/.work/out/*.deb
+ retention-days: 14
+ if-no-files-found: error
+
+ # Assemble + GPG-sign the apt repository and publish it to the gh-pages branch
+ # (served at https://apt.runner.kjanat.dev). Inert until the APT_GPG_PRIVATE_KEY
+ # secret is added to the `apt` environment — the .debs above ship regardless.
+ publish-apt:
+ name: publish apt repo
+ needs: build-deb
+ runs-on: ubuntu-latest
+ # actions: read so same-run download-artifact can reach the artifact API.
+ permissions: { contents: write, actions: read }
+ environment: { name: apt, url: "https://apt.runner.kjanat.dev" }
+ concurrency: { group: apt-publish, cancel-in-progress: false }
+ env:
+ GH_TOKEN: ${{ github.token }}
+ APT_DOMAIN: apt.runner.kjanat.dev
+ APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }}
+ APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }}
+ steps:
+ - name: gate on signing key
+ id: gate
+ run: |
+ if [[ -z "${APT_GPG_PRIVATE_KEY}" ]]; then
+ echo "::notice::APT_GPG_PRIVATE_KEY not set — apt repo publish skipped. The .deb files are still attached to the release. Add the secret to the 'apt' environment to enable apt.runner.kjanat.dev."
+ echo "enabled=false" >> "${GITHUB_OUTPUT}"
+ else
+ echo "enabled=true" >> "${GITHUB_OUTPUT}"
+ fi
+
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ if: steps.gate.outputs.enabled == 'true'
+ with: { persist-credentials: false }
+
+ - name: install apt tooling
+ if: steps.gate.outputs.enabled == 'true'
+ run: sudo apt-get update && sudo apt-get install -y apt-utils dpkg-dev
+
+ - name: download deb-packages artifact
+ if: steps.gate.outputs.enabled == 'true'
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with: { name: deb-packages, path: debian/.work/out }
+
+ - name: import signing key
+ id: gpg
+ if: steps.gate.outputs.enabled == 'true'
+ run: |
+ set -euo pipefail
+ printf '%s\n' "${APT_GPG_PRIVATE_KEY}" | gpg --batch --import
+ fpr=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
+ if [[ -z "${fpr}" ]]; then
+ echo "error: no secret key found after import" >&2
+ exit 1
+ fi
+ echo "key-id=${fpr}" >> "${GITHUB_OUTPUT}"
+
+ - name: fetch published repo state (gh-pages)
+ if: steps.gate.outputs.enabled == 'true'
+ run: |
+ set -euo pipefail
+ remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
+ # Preserve pool/ from prior releases; start fresh on the very first run.
+ if ! git clone --depth 1 --branch gh-pages "${remote}" apt-pages 2>/dev/null; then
+ echo "gh-pages branch absent — initializing a fresh one"
+ rm -rf apt-pages
+ git init -q apt-pages
+ git -C apt-pages checkout -q -b gh-pages
+ git -C apt-pages remote add origin "${remote}"
+ fi
+
+ - name: assemble + sign apt repository
+ if: steps.gate.outputs.enabled == 'true'
+ env:
+ DEB_DIR: debian/.work/out
+ APT_REPO_DIR: apt-pages
+ APT_SIGN: "true"
+ GPG_KEY_ID: ${{ steps.gpg.outputs.key-id }}
+ GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }}
+ run: bash "${GITHUB_WORKSPACE}/.github/scripts/publish/apt-repo.sh"
+
+ - name: commit + push gh-pages
+ if: steps.gate.outputs.enabled == 'true' && env.DRY_RUN != 'true'
+ working-directory: apt-pages
+ run: |
+ set -euo pipefail
+ git config user.name "Kaj Kowalski"
+ git config user.email "info@kajkowalski.nl"
+ git add -A
+ if git diff --cached --quiet; then
+ echo "apt repo already up to date — nothing to publish"
+ else
+ git commit -m "apt: publish ${RELEASE_TAG}"
+ git push -u origin gh-pages
+ fi
diff --git a/.gitignore b/.gitignore
index cbf5602..4c41bf4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
target/
dist/
downloads/
+debian/.work/
package-lock.json
node_modules/
.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 948318d..7c4ec5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,36 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
limitation). Strict semver regex on the version input refuses
anything containing `&`, `/`, `\`, or newlines before any `sed`
runs.
+- Debian/apt distribution channel. The `runner-run` package, built for
+ `amd64`, `arm64`, and `armhf` from the prebuilt release binaries
+ (glibc, dynamically linked — the same tarballs the AUR `-bin` and npm
+ channels repackage), is published two ways: a signed apt repository at
+ https://apt.runner.kjanat.dev (`apt-get install runner-run`) and as
+ `.deb` assets attached to every GitHub release (`apt install
+ ./runner-run__.deb`). Packages are native (no Debian
+ revision); a semver `-rc.N` prerelease maps to a Debian `~rc.N` so it
+ sorts before the final release.
+- Each `.deb` ships the `runner` and `run` binaries plus bash, zsh, and
+ fish completions auto-loaded from the canonical system dirs (and the
+ pwsh script at `/usr/share/runner/runner.ps1`), matching the AUR
+ packages. A `lintian` override documents the one false positive
+ (`embedded-library libyaml` — runner links the pure-Rust `yaml-rust2`,
+ not the C libyaml), so the package lints clean of errors.
+- `.github/workflows/debian-release.yml` runs on every `release:
+ published` event (plus manual `workflow_dispatch` + `dry-run`). The
+ `build-deb` job repackages and attaches the `.debs` (no secrets);
+ `publish-apt` assembles the static repo from that artifact, GPG-signs
+ `InRelease` + `Release.gpg`, and pushes it to the `gh-pages` branch
+ (served by GitHub Pages on the `apt.runner.kjanat.dev` custom domain).
+ It is gated behind an `apt` GitHub Environment and stays inert until
+ `APT_GPG_PRIVATE_KEY` is set — the `.deb` assets ship regardless.
+- `.github/scripts/build/deb-build.sh` (offline; repackages the release
+ tarballs into `.debs` using the checked-in `debian/control.in`
+ template) and `.github/scripts/publish/apt-repo.sh` (regenerates the
+ signed `dists/` index from `pool/`, preserving every past release so
+ `apt install runner-run=` keeps working). A strict semver regex
+ guards the version before any `sed` substitution, mirroring
+ `aur-prepare.sh`.
### Security
diff --git a/README.md b/README.md
index 6ea2fce..2ae2636 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,15 @@ Or on Arch Linux:
yay -S runner-run-bin
```
+Or on Debian / Ubuntu:
+
+```sh
+sudo install -d -m 0755 /etc/apt/keyrings
+curl -fsSL https://apt.runner.kjanat.dev/runner-run.gpg | sudo tee /etc/apt/keyrings/runner-run.gpg >/dev/null
+echo "deb [signed-by=/etc/apt/keyrings/runner-run.gpg] https://apt.runner.kjanat.dev stable main" | sudo tee /etc/apt/sources.list.d/runner-run.list >/dev/null
+sudo apt update && sudo apt install runner-run
+```
+
Other install methods
@@ -197,6 +206,12 @@ cargo install --path .
yay -S runner-run
```
+```sh
+# Debian/Ubuntu — direct .deb without the apt repo (arch ∈ amd64 arm64 armhf):
+curl -fsSLO https://github.com/kjanat/runner/releases/download/v0.12.0/runner-run_0.12.0_amd64.deb
+sudo apt install ./runner-run_0.12.0_amd64.deb
+```
+
```sh
curl -fsSLO https://raw.githubusercontent.com/kjanat/runner/master/install.sh
bash install.sh
diff --git a/debian/README.md b/debian/README.md
new file mode 100644
index 0000000..b6ccc4a
--- /dev/null
+++ b/debian/README.md
@@ -0,0 +1,110 @@
+# Debian / apt packaging
+
+One binary package, `runner-run`, built for three Debian architectures from
+the prebuilt GitHub release binaries (glibc, dynamically linked) — the same
+`runner-v-.tar.gz` assets the AUR `-bin` and npm channels
+repackage, so the shipped binaries are byte-for-byte the released ones.
+
+| Debian arch | Rust triple |
+| ----------- | ------------------------------- |
+| `amd64` | `x86_64-unknown-linux-gnu` |
+| `arm64` | `aarch64-unknown-linux-gnu` |
+| `armhf` | `armv7-unknown-linux-gnueabihf` |
+
+Each package installs the `runner` and `run` binaries plus bash, zsh, and fish
+completions into the canonical system autoload dirs (bash at
+`/usr/share/bash-completion/completions/{runner,run}`, zsh at
+`/usr/share/zsh/site-functions/{_runner,_run}`, fish at
+`/usr/share/fish/vendor_completions.d/{runner,run}.fish`) — no `eval` line in a
+user's rc. The PowerShell script has no autoload convention on Linux, so it is
+installed at `/usr/share/runner/runner.ps1` to dot-source from `$PROFILE`.
+Completions are clap-dynamic — the shell shells out to the binary for
+candidates, so tab-completing in a project picks up the *current* task list.
+
+## Install
+
+### apt repository (recommended)
+
+Signed repo at ; `apt-get upgrade` then tracks
+new releases:
+
+```sh
+sudo install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://apt.runner.kjanat.dev/runner-run.gpg \
+ | sudo tee /etc/apt/keyrings/runner-run.gpg >/dev/null
+echo "deb [signed-by=/etc/apt/keyrings/runner-run.gpg] https://apt.runner.kjanat.dev stable main" \
+ | sudo tee /etc/apt/sources.list.d/runner-run.list >/dev/null
+sudo apt-get update
+sudo apt-get install runner-run
+```
+
+### Direct .deb
+
+Every release also attaches the `.deb` files as assets:
+
+```sh
+ver=0.12.0 arch=amd64 # arch ∈ amd64 arm64 armhf
+curl -fsSLO "https://github.com/kjanat/runner/releases/download/v${ver}/runner-run_${ver}_${arch}.deb"
+sudo apt install "./runner-run_${ver}_${arch}.deb"
+```
+
+## Automation
+
+`.github/workflows/debian-release.yml` runs on every `release: published` event
+(and via manual `workflow_dispatch` with a `tag` input and a `dry-run`
+toggle). It has two jobs:
+
+1. **`build-deb`** repackages the release tarballs into the three `.debs`
+ (`.github/scripts/build/deb-download.sh` + `deb-build.sh`), lints them with
+ `lintian --fail-on error`, attaches them to the GitHub release, and uploads
+ them as a workflow artifact. No secrets — always runs.
+2. **`publish-apt`** assembles the static apt repo from that artifact, GPG-signs
+ `Release` (`InRelease` + detached `Release.gpg`), and pushes it to the
+ `gh-pages` branch (`.github/scripts/publish/apt-repo.sh`). `pool/` is fetched
+ from the existing branch first, so every past release stays installable
+ (`apt install runner-run=`). Gated behind the `apt` GitHub Environment
+ and **inert until `APT_GPG_PRIVATE_KEY` is set** — the `.debs` ship either
+ way.
+
+The checked-in `debian/control.in` is a template: `deb-build.sh` substitutes
+`@VERSION@` / `@ARCH@` / `@INSTALLED_SIZE@` per package.
+
+### Version mapping
+
+`vX.Y.Z` → `X.Y.Z`. A semver prerelease (`-rc.1`) becomes a Debian `~`
+prerelease (`0.13.0~rc.1`) so it sorts *before* the final release. Packages are
+native (no Debian revision) since upstream owns the packaging.
+
+## Maintainer setup (one-time)
+
+1. **Signing key** — generate an ed25519 key and export the armored secret:
+
+ ```sh
+ gpg --batch --passphrase '' \
+ --quick-generate-key 'runner-run apt repository ' ed25519 sign never
+ gpg --armor --export-secret-keys 'runner-run apt repository '
+ ```
+
+ Paste the armored secret into a repo **Environment** named `apt` as the
+ secret `APT_GPG_PRIVATE_KEY` (add `APT_GPG_PASSPHRASE` too if the key has
+ one). The public key is published automatically at
+ `https://apt.runner.kjanat.dev/runner-run.gpg`.
+2. **GitHub Pages** — after the first publish creates `gh-pages`, set Settings →
+ Pages → *Deploy from a branch* → `gh-pages` / root.
+3. **Custom domain** — DNS `apt.runner.kjanat.dev` → `CNAME kjanat.github.io`
+ (done). The workflow writes the `CNAME` file into `gh-pages`, so Pages serves
+ the domain and provisions HTTPS automatically.
+
+## Validation
+
+- **Dry run** (no upload, no push): Actions → `debian-release` → Run workflow,
+ set `tag` and tick `dry-run`. Builds + signs everything and prints the tree
+ without touching the release or `gh-pages`.
+- **Local build + lint** (on a Debian/Ubuntu box, from repo root):
+
+ ```sh
+ RELEASE_TAG=v0.12.0 GITHUB_REPOSITORY=kjanat/runner \
+ bash .github/scripts/build/deb-download.sh
+ VERSION=0.12.0 bash .github/scripts/build/deb-build.sh
+ lintian --fail-on error debian/.work/out/*.deb
+ ```
diff --git a/debian/control.in b/debian/control.in
new file mode 100644
index 0000000..8a64c3a
--- /dev/null
+++ b/debian/control.in
@@ -0,0 +1,22 @@
+Package: runner-run
+Version: @VERSION@
+Architecture: @ARCH@
+Maintainer: Kaj Kowalski
+Installed-Size: @INSTALLED_SIZE@
+Depends: libc6, libgcc-s1
+Section: devel
+Priority: optional
+Homepage: https://runner.kjanat.dev
+Description: Universal project task runner
+ runner inspects a project and dispatches to the right tool, so one set of
+ commands works in every repo no matter the underlying package manager or
+ task runner. Type `run test` / `run build` and it figures out whether the
+ project wants npm, pnpm, yarn, bun, cargo, deno, uv, poetry, go, make,
+ just, turbo, nx (and more), then runs the matching command.
+ .
+ It is monorepo-aware and, for a name that is not a defined task, falls
+ through to the package manager's exec primitive (npx, bunx, pnpm exec,
+ uv run, ...).
+ .
+ This package provides the `runner` and `run` binaries together with bash,
+ zsh, and fish shell completions for both.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..7e4f95e
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,27 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: runner-run
+Upstream-Contact: Kaj Kowalski
+Source: https://github.com/kjanat/runner
+
+Files: *
+Copyright: 2026 Kaj Kowalski
+License: MIT
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ .
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
diff --git a/debian/lintian-overrides b/debian/lintian-overrides
new file mode 100644
index 0000000..0403b60
--- /dev/null
+++ b/debian/lintian-overrides
@@ -0,0 +1,6 @@
+# runner links the pure-Rust `yaml-rust2` crate, not the C libyaml. lintian's
+# byte-signature heuristic matches the YAML tag URIs (`tag:yaml.org,2002:...`)
+# compiled into the binary and misreports an embedded copy of libyaml; there
+# is no C library vendored in.
+runner-run: embedded-library libyaml [usr/bin/run]
+runner-run: embedded-library libyaml [usr/bin/runner]
diff --git a/site/src/index.html b/site/src/index.html
index e204e4d..0045af9 100644
--- a/site/src/index.html
+++ b/site/src/index.html
@@ -47,6 +47,15 @@
runner
yay -S runner-run-bincopied
+
+
+ The apt copy adds the signed repo at apt.runner.kjanat.dev and installs — re-runnable, and
+ apt upgrade tracks new releases. .deb files (amd64, arm64, armhf) are attached to every release too.
+