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:

+
sudo install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://${APT_DOMAIN}/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_DOMAIN} ${APT_SUITE} ${APT_COMPONENT}" \\
+  | sudo tee /etc/apt/sources.list.d/runner-run.list >/dev/null
+sudo apt-get update
+sudo apt-get install runner-run
+

Architectures: ${APT_ARCHES}. Source & other install channels: +github.com/kjanat/runner.

+ + +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-bin +