diff options
Diffstat
| -rw-r--r-- | .github/workflows/ci.yml | 31 | +31 −0 |
| -rw-r--r-- | README.md | 19 | +19 −0 |
| -rwxr-xr-x | tasks/licenses | 6 | +2 −4 |
| -rwxr-xr-x | tasks/package | 134 | +134 −0 |
| -rwxr-xr-x | tests/package | 124 | +124 −0 |
| -rwxr-xr-x | tests/package-integration | 42 | +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' |
