aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-08-19 21:52:21 +0000
committerNikolay Govorov <me@govorov.online>2026-08-19 21:52:21 +0000
commit4d08e7bab19d8ff8fac87dfa1e3e7065454cde46 (patch)
tree85f9e901cccc819ebce193c57a133e4b1dae9074
parente5ca3be4349af3d1da2dde9d1165d52fb014a371 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
WIP: preserve package and publish prototype (wip/package-publish-prototype)
Diffstat
-rw-r--r--README.md7+7 −0
-rw-r--r--REUSE.toml1+1 −0
-rw-r--r--docs/releases.md49+49 −0
-rwxr-xr-xtasks/package101+101 −0
-rwxr-xr-xtasks/publish175+175 −0
5 files changed, 333 insertions, 0 deletions
diff --git a/README.md b/README.md
index 787b0b3..6c270c0 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,13 @@ mise run licenses
Consuming projects pin this repository by commit SHA.
+## Releases
+
+Projects build binaries themselves. Shared
+[`package`](tasks/package) and [`publish`](tasks/publish) tasks sign packages
+and publish package repositories consistently. Their short CLI contract is in
+[`docs/releases.md`](docs/releases.md).
+
## Tool provisioning
Each project declares its toolchain and standalone CLI dependencies in
diff --git a/REUSE.toml b/REUSE.toml
index bff4b49..3eeffd6 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -6,6 +6,7 @@ version = 1
[[annotations]]
path = [
"README.md",
+ "docs/releases.md",
]
SPDX-FileCopyrightText = "2026 Nikolay Govorov"
SPDX-License-Identifier = "CC-BY-4.0"
diff --git a/docs/releases.md b/docs/releases.md
new file mode 100644
--- /dev/null
+++ b/docs/releases.md
@@ -0,0 +1,49 @@
+# Packages and releases
+
+Projects build and stage binaries themselves. Infra only packages and
+publishes them.
+
+## Versions
+
+| Channel | Version | Git tag |
+| --- | --- | --- |
+| nightly | `<base>-nightly.<sequence>.g<short-sha>` | mutable `nightly` |
+| stable | `<base>` | `v<base>` |
+
+`base` is `MAJOR.MINOR.PATCH`.
+
+| Format | Nightly | Stable |
+| --- | --- | --- |
+| DEB | `<base>~nightly.<sequence>+<short-sha>-1` | `<base>-1` |
+| RPM | `<base>-0.nightly.<sequence>.<short-sha>` | `<base>-1` |
+| APK | `<base>_pre<sequence>~<short-sha>-r0` | `<base>-r0` |
+
+`package` uses the project-owned `nfpm.yaml`:
+
+```console
+mise run package -- \
+ --version VERSION --arch ARCH --output DIR \
+ [--config nfpm.yaml] deb rpm apk
+```
+
+`nfpm.yaml` reads `ARCH`, `VERSION`, and `RELEASE`. Signing uses
+`GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`, `GPG_KEY_ID`, and `APK_PRIVATE_KEY`.
+
+`publish` updates APT, RPM, and APK repositories on S3:
+
+```console
+mise run publish -- \
+ --name PROJECT \
+ --repository FULL_REPOSITORY_URL \
+ --version VERSION \
+ --input DIR \
+ --s3-bucket BUCKET \
+ --s3-endpoint URL \
+ --s3-public-url URL \
+ [--s3-provider PROVIDER] [--s3-region REGION] \
+ [--github-release]
+```
+
+The repository is always passed as a full URL and is only interpreted when
+`--github-release` is enabled. S3 credentials use `S3_ACCESS_KEY_ID` and
+`S3_SECRET_ACCESS_KEY`; signing uses the same GPG variables as `package`.
diff --git a/tasks/package b/tasks/package
new file mode 100755
--- /dev/null
+++ b/tasks/package
@@ -0,0 +1,101 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+#MISE description="Build signed Linux packages with nFPM"
+#MISE tools={"nfpm"="2.47.0"}
+
+set -eu
+
+usage() {
+ echo "usage: mise run package -- --version VERSION --arch ARCH --output DIR [--config FILE] deb|rpm|apk..." >&2
+ exit 2
+}
+
+config=nfpm.yaml
+version=
+arch=
+output=
+formats=
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --config) config=$2; shift 2 ;;
+ --version) version=$2; shift 2 ;;
+ --arch) arch=$2; shift 2 ;;
+ --output) output=$2; shift 2 ;;
+ deb|rpm|apk) formats="$formats $1"; shift ;;
+ *) usage ;;
+ esac
+done
+
+[ -n "$version" ] && [ -n "$arch" ] && [ -n "$output" ] && [ -n "$formats" ] || usage
+[ -f "$config" ] || { echo "package: $config not found" >&2; exit 1; }
+
+case "$version" in
+ *-nightly.*.g*)
+ base=${version%%-nightly.*}
+ nightly=${version#*-nightly.}
+ sequence=${nightly%%.g*}
+ commit=${nightly#*.g}
+ echo "$base" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' &&
+ echo "$sequence" | grep -Eq '^[0-9]+$' &&
+ echo "$commit" | grep -Eq '^[0-9a-f]{7,40}$' ||
+ { echo "package: invalid version: $version" >&2; exit 1; }
+ ;;
+ *)
+ echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ||
+ { echo "package: invalid version: $version" >&2; exit 1; }
+ base=$version
+ sequence=
+ commit=
+ ;;
+esac
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+mkdir -p "$output"
+
+case " $formats " in
+ *" deb "*|*" rpm "*)
+ : "${GPG_PRIVATE_KEY:?package: GPG_PRIVATE_KEY is required}"
+ : "${GPG_PASSPHRASE:?package: GPG_PASSPHRASE is required}"
+ : "${GPG_KEY_ID:?package: GPG_KEY_ID is required}"
+ 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"
+ gpg --batch --yes --pinentry-mode loopback \
+ --passphrase "$GPG_PASSPHRASE" --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
+ ;;
+esac
+
+case " $formats " in
+ *" apk "*)
+ : "${APK_PRIVATE_KEY:?package: APK_PRIVATE_KEY is required}"
+ APK_SIGNING_KEY="$work/signing.rsa"
+ printf '%s' "$APK_PRIVATE_KEY" > "$APK_SIGNING_KEY"
+ chmod 600 "$APK_SIGNING_KEY"
+ package_name=$(sed -n 's/^name:[[:space:]]*//p' "$config" | head -n 1)
+ [ -n "$package_name" ] || { echo "package: name is missing in $config" >&2; exit 1; }
+ openssl rsa -in "$APK_SIGNING_KEY" -pubout -out "$output/$package_name.rsa.pub"
+ export APK_SIGNING_KEY
+ ;;
+esac
+
+for format in $formats; do
+ case "$format:$sequence" in
+ deb:) VERSION=$base; RELEASE=1 ;;
+ rpm:) VERSION=$base; RELEASE=1 ;;
+ apk:) VERSION=$base; RELEASE=0 ;;
+ deb:*) VERSION="$base~nightly.$sequence+$commit"; RELEASE=1 ;;
+ rpm:*) VERSION=$base; RELEASE="0.nightly.$sequence.$commit" ;;
+ apk:*) VERSION="${base}_pre${sequence}~${commit}"; RELEASE=0 ;;
+ esac
+ ARCH=$arch
+ export VERSION RELEASE ARCH
+ nfpm package --config "$config" --packager "$format" --target "$output/"
+done
diff --git a/tasks/publish b/tasks/publish
new file mode 100755
--- /dev/null
+++ b/tasks/publish
@@ -0,0 +1,175 @@
+#!/bin/sh
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: 0BSD
+#MISE description="Publish signed packages to S3 and optionally GitHub Releases"
+#MISE tools={"gh"="2.96.0","rclone"="1.74.4"}
+
+set -eu
+
+usage() {
+ echo "usage: mise run publish -- --name NAME --repository URL --version VERSION --input DIR --s3-bucket BUCKET --s3-endpoint URL --s3-public-url URL [--s3-provider NAME] [--s3-region REGION] [--github-release]" >&2
+ exit 2
+}
+
+name=
+repository=
+version=
+input=
+bucket=
+endpoint=
+public_url=
+provider=Other
+region=
+github_release=false
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --name) name=$2; shift 2 ;;
+ --repository) repository=$2; shift 2 ;;
+ --version) version=$2; shift 2 ;;
+ --input) input=$2; shift 2 ;;
+ --s3-bucket) bucket=$2; shift 2 ;;
+ --s3-endpoint) endpoint=$2; shift 2 ;;
+ --s3-public-url) public_url=${2%/}; shift 2 ;;
+ --s3-provider) provider=$2; shift 2 ;;
+ --s3-region) region=$2; shift 2 ;;
+ --github-release) github_release=true; shift ;;
+ *) usage ;;
+ esac
+done
+
+[ -n "$name" ] && [ -n "$repository" ] && [ -n "$version" ] &&
+ [ -n "$input" ] && [ -n "$bucket" ] && [ -n "$endpoint" ] &&
+ [ -n "$public_url" ] || usage
+[ -d "$input" ] || { echo "publish: $input not found" >&2; exit 1; }
+
+case "$version" in
+ *-nightly.*.g*) channel=nightly; tag=nightly; title=nightly ;;
+ *) channel=stable; tag="v$version"; title=$version ;;
+esac
+
+: "${S3_ACCESS_KEY_ID:?publish: S3_ACCESS_KEY_ID is required}"
+: "${S3_SECRET_ACCESS_KEY:?publish: S3_SECRET_ACCESS_KEY is required}"
+: "${GPG_PRIVATE_KEY:?publish: GPG_PRIVATE_KEY is required}"
+: "${GPG_PASSPHRASE:?publish: GPG_PASSPHRASE is required}"
+: "${GPG_KEY_ID:?publish: GPG_KEY_ID is required}"
+
+input=$(cd "$input" && pwd)
+
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT HUP INT TERM
+export GNUPGHOME="$work/gnupg"
+mkdir -m 700 "$GNUPGHOME"
+
+RCLONE_CONFIG="$work/rclone.conf"
+export RCLONE_CONFIG
+cat > "$RCLONE_CONFIG" <<EOF
+[s3]
+type = s3
+provider = $provider
+access_key_id = $S3_ACCESS_KEY_ID
+secret_access_key = $S3_SECRET_ACCESS_KEY
+region = $region
+endpoint = $endpoint
+EOF
+chmod 600 "$RCLONE_CONFIG"
+
+printf '%s' "$GPG_PRIVATE_KEY" |
+ gpg --batch --yes --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
+gpg --export --armor "$GPG_KEY_ID" > "$work/public.gpg"
+rclone copyto "$work/public.gpg" "s3:$bucket/public.gpg"
+
+set -- "$input"/*.deb
+if [ -e "$1" ]; then
+ mkdir -p "$work/apt"
+ rclone copy "s3:$bucket/apt/" "$work/apt/"
+ mkdir -p "$work/apt/pool/$channel"
+ cp "$input"/*.deb "$work/apt/pool/$channel/"
+
+ architectures=$(find "$work/apt/pool" -name '*.deb' -exec dpkg-deb -f {} Architecture \; |
+ sort -u | tr '\n' ' ')
+ mkdir -p "$work/apt-cache"
+ for release_channel in stable nightly; do
+ mkdir -p "$work/apt/pool/$release_channel"
+ for architecture in $architectures; do
+ mkdir -p "$work/apt/dists/$release_channel/main/binary-$architecture"
+ done
+ done
+ cat > "$work/apt-ftparchive.conf" <<EOF
+Dir { ArchiveDir "$work/apt"; CacheDir "$work/apt-cache"; };
+Default { Packages::Compress ". gzip"; Packages::Extensions ".deb"; };
+TreeDefault {
+ Packages "\$(DIST)/\$(SECTION)/binary-\$(ARCH)/Packages";
+ BinCacheDB "packages-\$(DIST)-\$(ARCH).db";
+};
+Tree "dists/stable" {
+ Sections "main"; Architectures "$architectures"; Directory "pool/stable";
+};
+Tree "dists/nightly" {
+ Sections "main"; Architectures "$architectures"; Directory "pool/nightly";
+};
+EOF
+ (
+ cd "$work/apt"
+ apt-ftparchive generate "$work/apt-ftparchive.conf"
+ apt-ftparchive \
+ -o "APT::FTPArchive::Release::Codename=$channel" \
+ -o APT::FTPArchive::Release::Components=main \
+ -o "APT::FTPArchive::Release::Architectures=$architectures" \
+ release "dists/$channel/" > "dists/$channel/Release"
+ gpg --default-key="$GPG_KEY_ID" --batch --yes --pinentry-mode loopback \
+ --passphrase "$GPG_PASSPHRASE" -abs \
+ -o "dists/$channel/Release.gpg" "dists/$channel/Release"
+ gpg --default-key="$GPG_KEY_ID" --batch --yes --pinentry-mode loopback \
+ --passphrase "$GPG_PASSPHRASE" --clearsign \
+ -o "dists/$channel/InRelease" "dists/$channel/Release"
+ )
+ rclone copy "$work/apt/" "s3:$bucket/apt/"
+fi
+
+set -- "$input"/*.rpm
+if [ -e "$1" ]; then
+ mkdir -p "$work/rpm"
+ rclone copy "s3:$bucket/rpm/$channel/" "$work/rpm/"
+ cp "$input"/*.rpm "$work/rpm/"
+ createrepo_c --update "$work/rpm"
+ gpg --default-key="$GPG_KEY_ID" --batch --yes --pinentry-mode loopback \
+ --passphrase "$GPG_PASSPHRASE" --detach-sign --armor \
+ "$work/rpm/repodata/repomd.xml"
+ cat > "$work/rpm/$name-$channel.repo" <<EOF
+[$name-$channel]
+name=$name $channel
+gpgkey=$public_url/public.gpg
+baseurl=$public_url/rpm/$channel/
+enabled=1
+gpgcheck=1
+EOF
+ rclone copy "$work/rpm/" "s3:$bucket/rpm/$channel/"
+fi
+
+set -- "$input"/*.apk
+if [ -e "$1" ]; then
+ rclone copy "$input/" "s3:$bucket/" --include '*.rsa.pub'
+ rclone copy "$input/" "s3:$bucket/apk/$channel/" --include '*.apk' --include '*.rsa.pub'
+fi
+
+if [ "$github_release" = true ]; then
+ [ "$channel" = stable ] || {
+ git tag -f nightly
+ git push "$repository" --force refs/tags/nightly
+ for asset in $(gh release view "$tag" --repo "$repository" --json assets --jq '.assets[].name' 2>/dev/null || true); do
+ gh release delete-asset "$tag" "$asset" --repo "$repository" --yes
+ done
+ }
+ set --
+ for asset in "$input"/*.deb "$input"/*.rpm "$input"/*.apk "$input"/*.rsa.pub; do
+ [ -e "$asset" ] && set -- "$@" "$asset"
+ done
+ prerelease=
+ [ "$channel" = nightly ] && prerelease=--prerelease
+ gh release create "$tag" "$@" \
+ --repo "$repository" --title "$title" \
+ --notes "**Version**: $version" $prerelease ||
+ gh release upload "$tag" "$@" \
+ --repo "$repository" --clobber
+fi