diff options
Diffstat
| -rw-r--r-- | README.md | 7 | +7 −0 |
| -rw-r--r-- | REUSE.toml | 1 | +1 −0 |
| -rw-r--r-- | docs/releases.md | 49 | +49 −0 |
| -rwxr-xr-x | tasks/package | 101 | +101 −0 |
| -rwxr-xr-x | tasks/publish | 175 | +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 |
