Add SourceHut release artifact pipeline

ober

b87e4cbb27abbcd349e80bd0d638e48056fe946b

diff --git a/.builds/release-freebsd-amd64.yml b/.builds/release-freebsd-amd64.yml
new file mode 100644
index 0000000..2e4396c
--- /dev/null
+++ b/.builds/release-freebsd-amd64.yml
@@ -0,0 +1,42 @@
+image: freebsd/14.x
+arch: amd64
+packages:
+- bash
+- ca_root_nss
+- curl
+- git
+- gmake
+- hut
+oauth: git.sr.ht/OBJECTS:RW
+submitter:
+  git.sr.ht:
+    allow-refs:
+    - refs/tags/v*
+environment:
+  release_target: freebsd-amd64
+  sourcehut_repo: ~lisp/jerboa
+sources:
+- https://git.sr.ht/~lisp/jerboa
+tasks:
+- build: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*) version=${GIT_REF#refs/tags/} ;;
+      *) version=$(git describe --tags --always) ;;
+    esac
+    gmake jerboa
+    gmake release-artifact RELEASE_VERSION="$version" RELEASE_TARGET="$release_target"
+- upload-if-tag: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*)
+        tag=${GIT_REF#refs/tags/}
+        gmake release-upload RELEASE_VERSION="$tag" RELEASE_REPO="$sourcehut_repo"
+        ;;
+      *)
+        echo "not a tag build; skipping SourceHut git artifact upload"
+        ;;
+    esac
+artifacts:
+- jerboa/dist/release/jerboa-freebsd-amd64.tar.gz
+- jerboa/dist/release/jerboa-freebsd-amd64.tar.gz.sha256
diff --git a/.builds/release-linux-amd64.yml b/.builds/release-linux-amd64.yml
new file mode 100644
index 0000000..6d9d625
--- /dev/null
+++ b/.builds/release-linux-amd64.yml
@@ -0,0 +1,44 @@
+image: debian/stable
+arch: amd64
+packages:
+- bash
+- build-essential
+- ca-certificates
+- curl
+- file
+- git
+- hut
+- tar
+oauth: git.sr.ht/OBJECTS:RW
+submitter:
+  git.sr.ht:
+    allow-refs:
+    - refs/tags/v*
+environment:
+  release_target: linux-amd64
+  sourcehut_repo: ~lisp/jerboa
+sources:
+- https://git.sr.ht/~lisp/jerboa
+tasks:
+- build: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*) version=${GIT_REF#refs/tags/} ;;
+      *) version=$(git describe --tags --always) ;;
+    esac
+    make jerboa
+    make release-artifact RELEASE_VERSION="$version" RELEASE_TARGET="$release_target"
+- upload-if-tag: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*)
+        tag=${GIT_REF#refs/tags/}
+        make release-upload RELEASE_VERSION="$tag" RELEASE_REPO="$sourcehut_repo"
+        ;;
+      *)
+        echo "not a tag build; skipping SourceHut git artifact upload"
+        ;;
+    esac
+artifacts:
+- jerboa/dist/release/jerboa-linux-amd64.tar.gz
+- jerboa/dist/release/jerboa-linux-amd64.tar.gz.sha256
diff --git a/.builds/release-linux-arm64.yml b/.builds/release-linux-arm64.yml
new file mode 100644
index 0000000..f86d805
--- /dev/null
+++ b/.builds/release-linux-arm64.yml
@@ -0,0 +1,44 @@
+image: debian/stable
+arch: arm64
+packages:
+- bash
+- build-essential
+- ca-certificates
+- curl
+- file
+- git
+- hut
+- tar
+oauth: git.sr.ht/OBJECTS:RW
+submitter:
+  git.sr.ht:
+    allow-refs:
+    - refs/tags/v*
+environment:
+  release_target: linux-arm64
+  sourcehut_repo: ~lisp/jerboa
+sources:
+- https://git.sr.ht/~lisp/jerboa
+tasks:
+- build: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*) version=${GIT_REF#refs/tags/} ;;
+      *) version=$(git describe --tags --always) ;;
+    esac
+    make jerboa
+    make release-artifact RELEASE_VERSION="$version" RELEASE_TARGET="$release_target"
+- upload-if-tag: |
+    cd jerboa
+    case "${GIT_REF:-}" in
+      refs/tags/*)
+        tag=${GIT_REF#refs/tags/}
+        make release-upload RELEASE_VERSION="$tag" RELEASE_REPO="$sourcehut_repo"
+        ;;
+      *)
+        echo "not a tag build; skipping SourceHut git artifact upload"
+        ;;
+    esac
+artifacts:
+- jerboa/dist/release/jerboa-linux-arm64.tar.gz
+- jerboa/dist/release/jerboa-linux-arm64.tar.gz.sha256
diff --git a/Makefile b/Makefile
index 9ab1878..444f1f7 100644
--- a/Makefile
+++ b/Makefile
@@ -65,7 +65,7 @@ help:
 	@echo "                   Args: CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cross-cc>"
 	@echo "  binary-cross     Build a jerboa-bin for a foreign target (same args as chez-cross)"
 	@echo "  jerboa           Build the busybox-style multicall binary: dist/jerboa"
-	@echo "                   + symlinks dist/{jmcp,jlsp,jerbuild} (one image, all four modes)"
+	@echo "                   + symlinks dist/{jmcp,jlsp,jerbuild,jpkg} (one image, all five modes)"
 	@echo "  system-check     Validate .jerboa-system manifest"
 	@echo "  system-strict    Validate manifest and require declared outputs"
 	@echo "  system-report    Print unification health/provenance report"
@@ -80,8 +80,11 @@ help:
 	@echo "  install-policy-check Enforce no install-time code policy"
 	@echo "  tcb-report       Emit machine-readable TCB inventory"
 	@echo "  unification-check Run manifest/report/provenance/diff/audit checks"
-	@echo "  jerboa-portable  Build the multicall binary for host + linux-amd64 + freebsd-amd64"
-	@echo "                   (cross targets need: make chez-cross CHEZ_TARGET_MACHINE=ta6le|ta6fb ...)"
+	@echo "  jerboa-portable  Build multicall release targets: linux-amd64, linux-arm64, freebsd-amd64"
+	@echo "                   (cross targets need: make chez-cross CHEZ_TARGET_MACHINE=ta6le|tarm64le|ta6fb ...)"
+	@echo "  release-artifact Package one built target: RELEASE_VERSION=v0.1.0 RELEASE_TARGET=linux-amd64"
+	@echo "  release-artifacts Build/package all release targets"
+	@echo "  release-upload   Upload dist/release artifacts to a SourceHut git tag via hut"
 	@echo "  native-cross     Cross-build Rust native lib for a target"
 	@echo "                   Args: CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cc> [CROSS_NATIVE_FEATURES=tls,...]"
 	@echo "  typecheck        Typecheck Typed Jerboa sources"
@@ -104,11 +107,12 @@ help:
 	@echo ""
 	@echo "Cross-platform binaries (one static jerboa + jerbuild/jmcp/jlsp symlinks):"
 	@echo "  jerboa-linux-amd64    Cross-build for Linux x86_64 (musl)   -> dist/linux-amd64/"
+	@echo "  jerboa-linux-arm64    Cross-build for Linux arm64 (musl)     -> dist/linux-arm64/"
 	@echo "  jerboa-freebsd-amd64  Cross-build for FreeBSD amd64          -> dist/freebsd-amd64/"
 	@echo "  jerboa-cross          Generic: CHEZ_TARGET_MACHINE=<mt> CROSS_CC=<cc>"
 	@echo "                        (e.g. tarm64le -> dist/linux-arm64/, ta6fb -> freebsd-amd64)"
 	@echo "  jerbuild-cross        Cross-build standalone jerbuild-<mt>  (same args)"
-	@echo "  jerboa-portable       Host + linux-amd64 + freebsd-amd64 in one shot"
+	@echo "  jerboa-portable       linux-amd64 + linux-arm64 + freebsd-amd64 in one shot"
 	@echo "  NOTE one-time per target: build the cross Chez first, e.g."
 	@echo "    make chez-cross CHEZ_TARGET_MACHINE=ta6le CROSS_CC=x86_64-linux-musl-gcc   # Linux amd64"
 	@echo ""
@@ -379,7 +383,7 @@ install:
 #
 # Generic form (builds the cross Chez on demand via chez-cross):
 #   make jerboa-cross CHEZ_TARGET_MACHINE=ta6le CROSS_CC=x86_64-linux-musl-gcc
-.PHONY: jerboa-cross jerboa-linux-amd64 jerboa-freebsd-amd64 jerboa-portable
+.PHONY: jerboa-cross jerboa-linux-amd64 jerboa-linux-arm64 jerboa-freebsd-amd64 jerboa-portable
 jerboa-cross: chez build mcp-check lsp-gen chez-cross
 	@test -n "$(CHEZ_TARGET_MACHINE)" \
 		|| { echo "ERROR: set CHEZ_TARGET_MACHINE=<machine-type>" >&2; exit 1; }
@@ -405,6 +409,19 @@ jerboa-linux-amd64: chez build mcp-check lsp-gen
 		CC=x86_64-linux-musl-gcc \
 		$(SCHEME) --script support/build-jerboa-multicall.ss
 
+jerboa-linux-arm64: chez build mcp-check lsp-gen
+	@command -v aarch64-linux-musl-gcc >/dev/null 2>&1 || { echo "ERROR: aarch64-linux-musl-gcc not on PATH" >&2; exit 1; }
+	@if test ! -d "$(JERBOA_HOME)/.chez-cross-tarm64le" || test ! -f "$(CHEZ_BUILD_DIR)/xc-tarm64le/s/xpatch"; then \
+		echo "=== cross Chez (tarm64le) missing — building it first ==="; \
+		$(MAKE) chez-cross CHEZ_TARGET_MACHINE=tarm64le CROSS_CC=aarch64-linux-musl-gcc; \
+	fi
+	@echo "=== Cross-building jerboa multicall: linux-arm64 (tarm64le) ==="
+	@JERBOA_HOME=$(JERBOA_HOME) TARGET_MACHINE=tarm64le \
+		JERBOA_CROSS_PREFIX=$(JERBOA_HOME)/.chez-cross-tarm64le \
+		JERBOA_XPATCH=$(CHEZ_BUILD_DIR)/xc-tarm64le/s/xpatch \
+		CC=aarch64-linux-musl-gcc \
+		$(SCHEME) --script support/build-jerboa-multicall.ss
+
 jerboa-freebsd-amd64: chez build mcp-check lsp-gen
 	@test -x "$(JERBOA_HOME)/support/cross-cc-freebsd-amd64" || { echo "ERROR: support/cross-cc-freebsd-amd64 not found/executable" >&2; exit 1; }
 	@if test ! -d "$(JERBOA_HOME)/.chez-cross-ta6fb" || test ! -f "$(CHEZ_BUILD_DIR)/xc-ta6fb/s/xpatch"; then \
@@ -418,8 +435,32 @@ jerboa-freebsd-amd64: chez build mcp-check lsp-gen
 		CC="$(JERBOA_HOME)/support/cross-cc-freebsd-amd64" \
 		$(SCHEME) --script support/build-jerboa-multicall.ss
 
-# Host + the two cross targets.
-jerboa-portable: jerboa jerboa-linux-amd64 jerboa-freebsd-amd64
+# Release targets.
+jerboa-portable: jerboa-linux-amd64 jerboa-linux-arm64 jerboa-freebsd-amd64
+
+# ── Release packaging for SourceHut tag artifacts ───────────────────────────
+# Native SourceHut jobs call `release-artifact` after `make jerboa`. Local
+# maintainers can call `release-artifacts` to cross-build and package all
+# supported bootstrap toolchains in one pass.
+RELEASE_VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || git describe --tags --always --dirty 2>/dev/null || echo dev)
+RELEASE_TARGET ?= $(shell case "$$(uname -s)-$$(uname -m)" in Linux-x86_64) echo linux-amd64 ;; Linux-aarch64|Linux-arm64) echo linux-arm64 ;; FreeBSD-amd64|FreeBSD-x86_64) echo freebsd-amd64 ;; *) echo unknown ;; esac)
+RELEASE_TARGETS ?= linux-amd64 linux-arm64 freebsd-amd64
+RELEASE_DIR ?= dist/release
+RELEASE_REPO ?= ~lisp/jerboa
+
+.PHONY: release-artifact release-artifacts release-upload
+release-artifact:
+	@support/package-jerboa-release.sh "$(RELEASE_VERSION)" "$(RELEASE_TARGET)" "$(RELEASE_DIR)"
+
+release-artifacts: jerboa-portable
+	@for target in $(RELEASE_TARGETS); do \
+		support/package-jerboa-release.sh "$(RELEASE_VERSION)" "$$target" "$(RELEASE_DIR)"; \
+	done
+
+release-upload:
+	@support/sourcehut-release-upload.sh "$(RELEASE_VERSION)" "$(RELEASE_REPO)" \
+		"$(RELEASE_DIR)"/jerboa-"$(RELEASE_VERSION)"-*.tar.gz \
+		"$(RELEASE_DIR)"/jerboa-"$(RELEASE_VERSION)"-*.tar.gz.sha256
 
 # Cross-compiled jerbuild. Drives support/build-jerbuild.sh with TARGET_* env,
 # producing ./jerbuild-<machine> alongside the host ./jerbuild.
diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md
new file mode 100644
index 0000000..725d9e0
--- /dev/null
+++ b/docs/release-artifacts.md
@@ -0,0 +1,90 @@
+# Jerboa Release Artifacts
+
+Jerboa publishes project-local toolchain tarballs for package repos that should
+build without cloning and compiling this repository first.
+
+Each release target produces:
+
+```text
+jerboa-vX.Y.Z-linux-amd64.tar.gz
+jerboa-vX.Y.Z-linux-arm64.tar.gz
+jerboa-vX.Y.Z-freebsd-amd64.tar.gz
+```
+
+The archive layout is:
+
+```text
+jerboa-vX.Y.Z-<target>/bin/jerboa
+jerboa-vX.Y.Z-<target>/bin/jerbuild -> jerboa
+jerboa-vX.Y.Z-<target>/bin/jpkg     -> jerboa
+jerboa-vX.Y.Z-<target>/bin/jmcp     -> jerboa
+jerboa-vX.Y.Z-<target>/bin/jlsp     -> jerboa
+```
+
+## Build Locally
+
+```sh
+make release-artifacts RELEASE_VERSION=v0.1.0
+```
+
+This cross-builds and packages:
+
+```text
+linux-amd64
+linux-arm64
+freebsd-amd64
+```
+
+The output lands in `dist/release/`.
+
+## SourceHut CI
+
+The `.builds/release-*.yml` manifests build each release target natively on
+SourceHut:
+
+```text
+.builds/release-linux-amd64.yml
+.builds/release-linux-arm64.yml
+.builds/release-freebsd-amd64.yml
+```
+
+They are constrained to `refs/tags/v*`, so ordinary branch pushes do not run
+the release builders. The build job uses SourceHut's `GIT_REF` environment
+variable to name the artifact, because the checkout itself is pinned by commit
+hash. Every successful job exposes short-lived build artifacts for inspection.
+Tag builds also run:
+
+```sh
+make release-upload RELEASE_VERSION="$tag"
+```
+
+That attaches the versioned tarball and `.sha256` file to the SourceHut git tag
+with `hut git artifact upload --rev "$tag"`. SourceHut job artifacts are pruned;
+git tag artifacts are the stable download channel.
+
+## Downstream Bootstrap
+
+Package repos can vendor `support/ensure-jerboa.sh` and use:
+
+```make
+JERBOA_VERSION ?= v0.1.0
+JERBOA_BINDIR := .jerboa/bin
+
+.PHONY: install
+install: $(JERBOA_BINDIR)/jerboa
+	PATH="$(CURDIR)/$(JERBOA_BINDIR):$$PATH" "$(JERBOA_BINDIR)/jpkg" install
+
+$(JERBOA_BINDIR)/jerboa:
+	sh support/ensure-jerboa.sh "$(JERBOA_VERSION)" "$(JERBOA_BINDIR)"
+```
+
+The bootstrap script downloads from:
+
+```text
+https://git.sr.ht/~lisp/jerboa/refs/download/<tag>/<artifact>
+```
+
+Override `JERBOA_RELEASE_REPO`, `JERBOA_RELEASE_ORIGIN`, or
+`JERBOA_RELEASE_BASE` for mirrors or forks. Override
+`JERBOA_RELEASE_TARGET` only for tests or unusual hosts where `uname` detection
+is not enough.
diff --git a/support/ensure-jerboa.sh b/support/ensure-jerboa.sh
new file mode 100755
index 0000000..5700d7c
--- /dev/null
+++ b/support/ensure-jerboa.sh
@@ -0,0 +1,119 @@
+#!/bin/sh
+# Bootstrap a project-local Jerboa toolchain from SourceHut release artifacts.
+#
+# Usage:
+#   support/ensure-jerboa.sh v0.1.0 .jerboa/bin
+#
+# Override the artifact location with:
+#   JERBOA_RELEASE_BASE=https://example.org/releases/v0.1.0
+# or the SourceHut repo with:
+#   JERBOA_RELEASE_REPO=~lisp/jerboa
+# For testing or unusual hosts, override target detection with:
+#   JERBOA_RELEASE_TARGET=linux-amd64
+
+set -eu
+
+usage() {
+    echo "Usage: $0 VERSION [BINDIR]" >&2
+}
+
+[ "${1:-}" ] || { usage; exit 2; }
+
+version=$1
+bindir=${2:-.jerboa/bin}
+repo=${JERBOA_RELEASE_REPO:-~lisp/jerboa}
+origin=${JERBOA_RELEASE_ORIGIN:-https://git.sr.ht}
+
+if [ "${JERBOA_RELEASE_TARGET:-}" ]; then
+    target=$JERBOA_RELEASE_TARGET
+else
+    os=$(uname -s)
+    arch=$(uname -m)
+    case "$os-$arch" in
+        Linux-x86_64|Linux-amd64) target=linux-amd64 ;;
+        Linux-aarch64|Linux-arm64) target=linux-arm64 ;;
+        FreeBSD-amd64|FreeBSD-x86_64) target=freebsd-amd64 ;;
+        *)
+            echo "ERROR: unsupported platform: $os $arch" >&2
+            exit 1
+            ;;
+    esac
+fi
+
+case "$target" in
+    linux-amd64|linux-arm64|freebsd-amd64) ;;
+    *)
+        echo "ERROR: unsupported release target: $target" >&2
+        exit 1
+        ;;
+esac
+
+file="jerboa-${version}-${target}.tar.gz"
+base=${JERBOA_RELEASE_BASE:-${origin}/${repo}/refs/download/${version}}
+url="${base%/}/${file}"
+sum_url="${url}.sha256"
+
+download() {
+    src=$1
+    dst=$2
+    if command -v curl >/dev/null 2>&1; then
+        curl -fsSL "$src" -o "$dst"
+    elif command -v fetch >/dev/null 2>&1; then
+        fetch -q -o "$dst" "$src"
+    elif command -v wget >/dev/null 2>&1; then
+        wget -q -O "$dst" "$src"
+    else
+        echo "ERROR: need curl, fetch, or wget to download Jerboa" >&2
+        exit 1
+    fi
+}
+
+sha256_file() {
+    path=$1
+    if command -v sha256sum >/dev/null 2>&1; then
+        sha256sum "$path" | awk '{print $1}'
+    elif command -v shasum >/dev/null 2>&1; then
+        shasum -a 256 "$path" | awk '{print $1}'
+    elif command -v sha256 >/dev/null 2>&1; then
+        sha256 -q "$path"
+    else
+        echo "ERROR: need sha256sum, shasum, or sha256" >&2
+        exit 1
+    fi
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-bootstrap.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+
+archive="$tmp/$file"
+sum_file="$tmp/$file.sha256"
+
+echo "fetching $url"
+download "$url" "$archive"
+download "$sum_url" "$sum_file"
+
+expected=$(awk '{print $1; exit}' "$sum_file")
+actual=$(sha256_file "$archive")
+if [ "$expected" != "$actual" ]; then
+    echo "ERROR: checksum mismatch for $file" >&2
+    echo "expected: $expected" >&2
+    echo "actual:   $actual" >&2
+    exit 1
+fi
+
+mkdir -p "$tmp/extract"
+tar -xzf "$archive" -C "$tmp/extract"
+root="$tmp/extract/jerboa-${version}-${target}"
+[ -x "$root/bin/jerboa" ] || {
+    echo "ERROR: archive did not contain bin/jerboa" >&2
+    exit 1
+}
+
+mkdir -p "$bindir"
+cp "$root/bin/jerboa" "$bindir/jerboa"
+chmod 0755 "$bindir/jerboa"
+for link in jmcp jlsp jerbuild jpkg; do
+    ln -sf jerboa "$bindir/$link"
+done
+
+echo "installed $bindir/{jerboa,jmcp,jlsp,jerbuild,jpkg}"
diff --git a/support/package-jerboa-release.sh b/support/package-jerboa-release.sh
new file mode 100755
index 0000000..631a8c5
--- /dev/null
+++ b/support/package-jerboa-release.sh
@@ -0,0 +1,84 @@
+#!/bin/sh
+# Package one Jerboa multicall binary as a SourceHut release artifact.
+
+set -eu
+
+usage() {
+    echo "Usage: $0 VERSION TARGET [RELEASE_DIR]" >&2
+    echo "  TARGET: linux-amd64 | linux-arm64 | freebsd-amd64" >&2
+}
+
+[ "${1:-}" ] || { usage; exit 2; }
+[ "${2:-}" ] || { usage; exit 2; }
+
+version=$1
+target=$2
+release_dir=${3:-dist/release}
+name="jerboa-${version}-${target}"
+versioned="${release_dir}/${name}.tar.gz"
+alias_tar="${release_dir}/jerboa-${target}.tar.gz"
+
+case "$target" in
+    linux-amd64|linux-arm64|freebsd-amd64) ;;
+    *)
+        echo "ERROR: unsupported release target: $target" >&2
+        exit 1
+        ;;
+esac
+
+if [ -x "dist/${target}/jerboa" ]; then
+    source_bin="dist/${target}/jerboa"
+elif [ -x "dist/jerboa" ]; then
+    source_bin="dist/jerboa"
+else
+    echo "ERROR: no built jerboa binary found for ${target}" >&2
+    echo "       expected dist/${target}/jerboa or dist/jerboa" >&2
+    exit 1
+fi
+
+file_desc=$(file "$source_bin")
+case "$target:$file_desc" in
+    linux-amd64:*"ELF 64-bit LSB executable, x86-64"*) ;;
+    linux-arm64:*"ELF 64-bit LSB executable, ARM aarch64"*) ;;
+    freebsd-amd64:*"ELF 64-bit LSB executable, x86-64"*"FreeBSD"*) ;;
+    *)
+        echo "ERROR: $source_bin does not look like target $target" >&2
+        echo "       file: $file_desc" >&2
+        exit 1
+        ;;
+esac
+
+mkdir -p "$release_dir"
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/jerboa-release.XXXXXX")
+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
+
+mkdir -p "$tmp/$name/bin"
+cp "$source_bin" "$tmp/$name/bin/jerboa"
+chmod 0755 "$tmp/$name/bin/jerboa"
+for link in jmcp jlsp jerbuild jpkg; do
+    ln -sf jerboa "$tmp/$name/bin/$link"
+done
+
+cwd=$(pwd)
+(cd "$tmp" && tar -czf "$cwd/$versioned" "$name")
+
+if command -v sha256sum >/dev/null 2>&1; then
+    sum=$(sha256sum "$versioned" | awk '{print $1}')
+elif command -v shasum >/dev/null 2>&1; then
+    sum=$(shasum -a 256 "$versioned" | awk '{print $1}')
+elif command -v sha256 >/dev/null 2>&1; then
+    sum=$(sha256 -q "$versioned")
+else
+    echo "ERROR: need sha256sum, shasum, or sha256" >&2
+    exit 1
+fi
+
+base=$(basename "$versioned")
+printf '%s  %s\n' "$sum" "$base" > "${versioned}.sha256"
+
+# Static aliases make builds.sr.ht job artifacts easy to list literally.
+cp "$versioned" "$alias_tar"
+printf '%s  %s\n' "$sum" "$(basename "$alias_tar")" > "${alias_tar}.sha256"
+
+echo "release artifact: $versioned"
+echo "sha256: $sum"
diff --git a/support/sourcehut-release-upload.sh b/support/sourcehut-release-upload.sh
new file mode 100755
index 0000000..5a08368
--- /dev/null
+++ b/support/sourcehut-release-upload.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# Upload versioned release artifacts to a SourceHut git tag.
+
+set -eu
+
+usage() {
+    echo "Usage: $0 VERSION REPO FILE..." >&2
+    echo "  REPO example: ~lisp/jerboa" >&2
+}
+
+[ "${1:-}" ] || { usage; exit 2; }
+[ "${2:-}" ] || { usage; exit 2; }
+
+version=$1
+repo=$2
+shift 2
+
+[ "$#" -gt 0 ] || { usage; exit 2; }
+
+if git rev-parse --git-dir >/dev/null 2>&1 &&
+   ! git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then
+    echo "warning: ${version} is not a local git tag; relying on SourceHut remote tag" >&2
+fi
+
+if ! command -v hut >/dev/null 2>&1; then
+    echo "ERROR: hut is required to upload SourceHut git artifacts" >&2
+    exit 1
+fi
+
+files=
+for file in "$@"; do
+    case "$file" in
+        *'*'*) continue ;;
+    esac
+    if [ -f "$file" ]; then
+        files="${files}${files:+
+}$file"
+    fi
+done
+
+if [ -z "$files" ]; then
+    echo "ERROR: no release files matched" >&2
+    exit 1
+fi
+
+printf '%s\n' "$files" | while IFS= read -r file; do
+    echo "uploading $file to $repo tag $version"
+    hut git -r "$repo" artifact upload "$file" --rev "$version"
+done