aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-08-19 23:22:10 +0100
committerNikolay Govorov <me@govorov.online>2026-08-19 23:48:04 +0100
commit9e7330a3e5bde39a87e0ac1b76c9e85db7a6d64a (patch)
tree92bf1f73708660968156f9f2870f470460a0c5d7
parente5ca3be4349af3d1da2dde9d1165d52fb014a371 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Add package task
Diffstat
-rw-r--r--.github/workflows/ci.yml31+31 −0
-rw-r--r--README.md19+19 −0
-rwxr-xr-xtasks/licenses6+2 −4
-rwxr-xr-xtasks/package134+134 −0
-rwxr-xr-xtests/package124+124 −0
-rwxr-xr-xtests/package-integration42+42 −0
6 files changed, 352 insertions, 4 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ name: Package task
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
+ with:
+ version: 2026.7.5
+ experimental: true
+ install: false
+
+ - name: Check package contract
+ run: |
+ tests/package
+ tests/package-integration
diff --git a/README.md b/README.md
index 787b0b3..4635e04 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,25 @@ mise run licenses
Consuming projects pin this repository by commit SHA.
+## Packaging
+
+Projects build and stage their own binaries and keep their nFPM configuration.
+The shared [`package`](tasks/package) task creates only the formats explicitly
+requested by a project: nFPM packages (`deb`, `rpm`, or `apk`) and portable
+archives (`tar.gz` or `zip`). Repository publication is intentionally outside
+this task.
+
+```console
+mise run package -- \
+ --version VERSION --arch ARCH --output DIR \
+ [--config nfpm.yaml] [--apk-public-key NAME.rsa.pub] \
+ deb rpm apk
+
+mise run package -- \
+ --archive-root DIR --archive-name NAME --output DIR \
+ tar.gz zip
+```
+
## Tool provisioning
Each project declares its toolchain and standalone CLI dependencies in
diff --git a/tasks/licenses b/tasks/licenses
index cb8a932..5956052 100755
--- a/tasks/licenses
+++ b/tasks/licenses
@@ -1,15 +1,13 @@
-#!/bin/sh
+#!/bin/sh -eu
# SPDX-FileCopyrightText: 2026 Nikolay Govorov
# SPDX-License-Identifier: 0BSD
#MISE description="Verify repository licensing metadata"
#MISE tools={"pipx:reuse"="6.2.0","aqua:EmbarkStudios/cargo-deny"="0.19.0"}
-set -eu
-
check_copyright_headers() {
invalid_headers=$(
git grep -n -I -E \
- 'Copyright[[:space:]]+(\([cC]\)|©)|SPDX-FileCopyrightText:' \
+ '^((<!--|#|//|/\*|\*)[[:space:]]*)?(Copyright[[:space:]]+(\([cC]\)|©)|SPDX-FileCopyrightText:)' \
-- . \
':(exclude)*.md' \
':(exclude)LICENSE' \
diff --git a/tasks/package b/tasks/package
new file mode 100755
--- /dev/null
+++ b/tasks/package
@@ -0,0 +1,134 @@
+#!/bin/sh -eu
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+#MISE description="Build release archives and signed Linux packages"
+#MISE tools={"nfpm"="2.47.0"}
+
+usage() {
+ echo "usage: mise run package -- --output DIR [--config FILE] [--version VERSION --arch ARCH] [--apk-public-key FILE] [--archive-root DIR --archive-name NAME] deb|rpm|apk|tar.gz|zip..." >&2
+ exit 2
+}
+
+setup_gpg_signing() {
+ : "${GPG_KEY_ID:?package: GPG_KEY_ID is required with GPG_PRIVATE_KEY}"
+ : "${GPG_PASSPHRASE:?package: GPG_PASSPHRASE is required with GPG_PRIVATE_KEY}"
+
+ export GNUPGHOME="$work/gnupg"
+ mkdir -m 700 "$GNUPGHOME"
+
+ SIGNING_PRIVATE_KEY="$work/signing.asc"
+ printf '%s' "$GPG_PRIVATE_KEY" > "$SIGNING_PRIVATE_KEY"
+
+ chmod 600 "$SIGNING_PRIVATE_KEY"
+ printf '%s\n' "$GPG_PASSPHRASE" | gpg --batch --yes \
+ --pinentry-mode loopback --passphrase-fd 0 \
+ --import "$SIGNING_PRIVATE_KEY"
+
+ NFPM_PASSPHRASE=$GPG_PASSPHRASE
+ GPG_KEY_ID=$(printf '%s' "$GPG_KEY_ID" | sed 's/.*\(.\{16\}\)$/\1/')
+ export SIGNING_PRIVATE_KEY NFPM_PASSPHRASE GPG_KEY_ID
+ unset GPG_PRIVATE_KEY GPG_PASSPHRASE
+}
+
+config=nfpm.yaml
+version=
+arch=
+output=
+archive_root=
+archive_name=
+apk_public_key=
+formats=
+system_formats=
+archive_formats=
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --config) [ "$#" -ge 2 ] || usage; config=$2; shift 2 ;;
+ --version) [ "$#" -ge 2 ] || usage; version=$2; shift 2 ;;
+ --arch) [ "$#" -ge 2 ] || usage; arch=$2; shift 2 ;;
+ --output) [ "$#" -ge 2 ] || usage; output=$2; shift 2 ;;
+ --archive-root) [ "$#" -ge 2 ] || usage; archive_root=$2; shift 2 ;;
+ --archive-name) [ "$#" -ge 2 ] || usage; archive_name=$2; shift 2 ;;
+ --apk-public-key) [ "$#" -ge 2 ] || usage; apk_public_key=$2; shift 2 ;;
+ deb|rpm|apk)
+ formats="$formats $1"
+ system_formats="$system_formats $1"
+ shift
+ ;;
+ tar.gz|zip)
+ formats="$formats $1"
+ archive_formats="$archive_formats $1"
+ shift
+ ;;
+ *) usage ;;
+ esac
+done
+
+[ -n "$output" ] && [ -n "$formats" ] || usage
+
+if [ -n "$system_formats" ]; then
+ [ -n "$version" ] && [ -n "$arch" ] || usage
+ [ -f "$config" ] || { echo "package: $config not found" >&2; exit 1; }
+fi
+
+if [ -n "$archive_formats" ]; then
+ [ -n "$archive_root" ] && [ -n "$archive_name" ] || usage
+ [ -d "$archive_root" ] || { echo "package: $archive_root not found" >&2; exit 1; }
+ case "$archive_name" in
+ *[!A-Za-z0-9._-]*|.|..) echo "package: invalid archive name: $archive_name" >&2; exit 1 ;;
+ esac
+fi
+
+case "$apk_public_key" in
+ *[!A-Za-z0-9._-]*|.|..) echo "package: invalid APK public key name: $apk_public_key" >&2; exit 1 ;;
+esac
+
+mkdir -p "$output"
+output=$(cd "$output" && pwd)
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+case " $system_formats " in
+ *" deb "*|*" rpm "*)
+ if [ -n "${GPG_PRIVATE_KEY:-}" ]; then
+ setup_gpg_signing
+ fi
+ ;;
+esac
+
+case " $system_formats " in
+ *" apk "*)
+ if [ -n "${APK_PRIVATE_KEY:-}" ]; then
+ APK_SIGNING_KEY="$work/signing.rsa"
+ printf '%s' "$APK_PRIVATE_KEY" > "$APK_SIGNING_KEY"
+ chmod 600 "$APK_SIGNING_KEY"
+ export APK_SIGNING_KEY
+ unset APK_PRIVATE_KEY
+ fi
+ if [ -n "${APK_SIGNING_KEY:-}" ]; then
+ [ -n "$apk_public_key" ] || {
+ echo "package: --apk-public-key is required for signed APK packages" >&2
+ exit 1
+ }
+ openssl rsa -in "$APK_SIGNING_KEY" -pubout -out "$output/$apk_public_key"
+ fi
+ ;;
+esac
+
+for format in $formats; do
+ case "$format" in
+ deb|rpm|apk)
+ ARCH=$arch VERSION=$version
+ export ARCH VERSION
+ nfpm package --config "$config" --packager "$format" --target "$output/"
+ ;;
+ tar.gz)
+ tar -czf "$output/$archive_name.tar.gz" -C "$archive_root" .
+ ;;
+ zip)
+ rm -f "$output/$archive_name.zip"
+ (cd "$archive_root" && zip -qry "$output/$archive_name.zip" .)
+ ;;
+ esac
+done
diff --git a/tests/package b/tests/package
new file mode 100755
--- /dev/null
+++ b/tests/package
@@ -0,0 +1,124 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+mkdir -p "$work/bin" "$work/stage/sub" "$work/out"
+printf 'payload\n' > "$work/stage/tool"
+printf 'nested\n' > "$work/stage/sub/file"
+printf 'outside\n' > "$work/outside"
+ln -s "$work/outside" "$work/stage/outside-link"
+
+cat > "$work/bin/nfpm" <<'EOF'
+#!/bin/sh
+set -eu
+[ -z "${GPG_PRIVATE_KEY:-}" ] && [ -z "${APK_PRIVATE_KEY:-}" ] || {
+ echo 'raw private key leaked to nFPM' >&2
+ exit 1
+}
+printf 'ARCH=%s\nVERSION=%s\nGPG_KEY_ID=%s\n' "$ARCH" "$VERSION" "${GPG_KEY_ID:-}" >> "$PACKAGE_TEST_LOG"
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --packager) packager=$2; shift 2 ;;
+ --target) target=$2; shift 2 ;;
+ *) shift ;;
+ esac
+done
+printf '%s\n' "$packager" >> "$PACKAGE_TEST_LOG"
+: > "$target/test.$packager"
+EOF
+
+cat > "$work/bin/gpg" <<'EOF'
+#!/bin/sh
+set -eu
+printf 'gpg\n' >> "$PACKAGE_TEST_LOG"
+EOF
+
+cat > "$work/bin/openssl" <<'EOF'
+#!/bin/sh
+set -eu
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -out) output=$2; shift 2 ;;
+ *) shift ;;
+ esac
+done
+printf 'public key\n' > "$output"
+EOF
+
+chmod +x "$work/bin/nfpm" "$work/bin/gpg" "$work/bin/openssl"
+printf 'name: test\n' > "$work/nfpm.yaml"
+export PACKAGE_TEST_LOG="$work/package.log"
+PATH="$work/bin:$PATH"
+export PATH
+
+"$root/tasks/package" \
+ --output "$work/out" \
+ --archive-root "$work/stage" \
+ --archive-name test-linux-amd64 \
+ tar.gz zip
+
+test -f "$work/out/test-linux-amd64.tar.gz"
+test -f "$work/out/test-linux-amd64.zip"
+tar -tzf "$work/out/test-linux-amd64.tar.gz" | grep -q './tool'
+unzip -l "$work/out/test-linux-amd64.zip" | grep -q 'sub/file'
+test "$(unzip -p "$work/out/test-linux-amd64.zip" outside-link)" = "$work/outside"
+
+"$root/tasks/package" \
+ --config "$work/nfpm.yaml" \
+ --version '1.2.3~nightly.42' \
+ --arch arm64 \
+ --output "$work/out" \
+ deb rpm
+
+test -f "$work/out/test.deb"
+test -f "$work/out/test.rpm"
+grep -q '^ARCH=arm64$' "$work/package.log"
+grep -q '^VERSION=1.2.3~nightly.42$' "$work/package.log"
+
+GPG_PRIVATE_KEY=private \
+GPG_PASSPHRASE=passphrase \
+GPG_KEY_ID=0123456789ABCDEF0123456789ABCDEF01234567 \
+APK_PRIVATE_KEY=apk-private \
+ "$root/tasks/package" \
+ --config "$work/nfpm.yaml" \
+ --version 1.2.3 \
+ --arch amd64 \
+ --output "$work/out" \
+ --apk-public-key test.rsa.pub \
+ deb apk
+
+test -f "$work/out/test.apk"
+test -f "$work/out/test.rsa.pub"
+grep -q '^gpg$' "$work/package.log"
+grep -q '^GPG_KEY_ID=89ABCDEF01234567$' "$work/package.log"
+
+if "$root/tasks/package" --output "$work/out" deb 2>/dev/null; then
+ echo 'package test: accepted system format without version and architecture' >&2
+ exit 1
+fi
+
+if "$root/tasks/package" \
+ --output "$work/out" \
+ --archive-root "$work/stage" \
+ --archive-name '../escape' \
+ zip 2>/dev/null; then
+ echo 'package test: accepted unsafe archive name' >&2
+ exit 1
+fi
+
+if APK_SIGNING_KEY="$work/apk.rsa" "$root/tasks/package" \
+ --config "$work/nfpm.yaml" \
+ --version 1.2.3 \
+ --arch amd64 \
+ --output "$work/out" \
+ --apk-public-key '../escape' \
+ apk 2>/dev/null; then
+ echo 'package test: accepted unsafe APK public key name' >&2
+ exit 1
+fi
diff --git a/tests/package-integration b/tests/package-integration
new file mode 100755
--- /dev/null
+++ b/tests/package-integration
@@ -0,0 +1,42 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+
+mkdir -p "$work/stage"
+printf 'package contract\n' > "$work/stage/package-contract"
+cat > "$work/nfpm.yaml" <<'EOF'
+name: package-contract
+arch: ${ARCH}
+version: ${VERSION}
+platform: linux
+maintainer: Dimidium Labs <me@govorov.online>
+description: Shared package task integration fixture
+license: 0BSD
+EOF
+
+mise --cd "$root" run package -- \
+ --config "$work/nfpm.yaml" \
+ --version '1.2.3~nightly.42' \
+ --arch amd64 \
+ --output "$work/out" \
+ --archive-root "$work/stage" \
+ --archive-name package-contract-linux-amd64 \
+ deb rpm apk tar.gz zip
+
+test "$(find "$work/out" -maxdepth 1 -name '*.deb' | wc -l)" -eq 1
+test "$(find "$work/out" -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1
+test "$(find "$work/out" -maxdepth 1 -name '*.apk' | wc -l)" -eq 1
+test -f "$work/out/package-contract-linux-amd64.tar.gz"
+test -f "$work/out/package-contract-linux-amd64.zip"
+
+test "$(dpkg-deb -f "$work/out"/*.deb Package)" = package-contract
+test "$(dpkg-deb -f "$work/out"/*.deb Version)" = '1.2.3~nightly.42'
+test "$(dpkg-deb -f "$work/out"/*.deb Architecture)" = amd64
+tar -tzf "$work/out/package-contract-linux-amd64.tar.gz" | grep -q './package-contract'
+unzip -l "$work/out/package-contract-linux-amd64.zip" | grep -q 'package-contract'