Skip to content

wheels-released

wheels-released #99

# Receiver workflow for `wheels-dev/apt-wheels` (R2-backed architecture).
#
# Listens for `repository_dispatch` from `wheels-dev/wheels`'s release workflow,
# downloads the new `.deb` asset from the upstream GitHub Release, syncs the
# existing apt repo state from R2 (so apt-ftparchive can see prior versions),
# regenerates `Packages.gz` / `Release` / `InRelease` via `apt-ftparchive`,
# signs with GPG, and uploads the changed tree to the `wheels-apt` R2 bucket
# (which is served at https://apt.wheels.dev via R2 custom-domain).
#
# This repo no longer commits pool/ or dists/ — those are R2-resident only.
# The repo's job is to hold the WORKFLOW + REGEN SCRIPT + LANDING + GPG KEY.
#
# Trigger payload contract (sender: wheels-dev/wheels release.yml):
# event_type: "wheels-released"
# client_payload:
# version: "<x.y.z>" or "<x.y.z>-snapshot.<n>"
# channel: "stable" | "bleeding-edge"
#
# Manual dispatch is supported via `workflow_dispatch` for backfill /
# disaster-recovery.
name: Publish to apt.wheels.dev
on:
repository_dispatch:
types: [wheels-released]
workflow_dispatch:
inputs:
version:
description: 'Wheels version (e.g. 4.0.1 or 4.0.1-snapshot.1700)'
required: true
type: string
channel:
description: 'Release channel'
required: true
default: stable
type: choice
options:
- stable
- bleeding-edge
permissions:
contents: read
concurrency:
# Serialize across channels — apt-ftparchive scans the whole pool, so
# parallel runs would race on the regenerated Packages.gz / Release uploads.
group: publish-apt
cancel-in-progress: false
env:
R2_BUCKET: wheels-apt
CLOUDFLARE_ACCOUNT_ID: "511d04f367103ec276d875ab41a24dea"
jobs:
publish:
name: Publish ${{ github.event.client_payload.version || inputs.version }} (${{ github.event.client_payload.channel || inputs.channel }})
runs-on: ubuntu-latest
steps:
- name: Resolve inputs
id: inputs
env:
CLIENT_VERSION: ${{ github.event.client_payload.version }}
CLIENT_CHANNEL: ${{ github.event.client_payload.channel }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_CHANNEL: ${{ inputs.channel }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
VERSION="$CLIENT_VERSION"
CHANNEL="$CLIENT_CHANNEL"
else
VERSION="$INPUT_VERSION"
CHANNEL="$INPUT_CHANNEL"
fi
if [ -z "$VERSION" ] || [ -z "$CHANNEL" ]; then
echo "::error::Both version and channel are required."
exit 1
fi
case "$CHANNEL" in
stable|bleeding-edge) ;;
*) echo "::error::Unsupported channel: $CHANNEL"; exit 1 ;;
esac
case "$CHANNEL" in
stable) PKG="wheels"; UPSTREAM_REPO="wheels-dev/wheels" ;;
bleeding-edge) PKG="wheels-be"; UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;;
esac
{
echo "version=$VERSION"
echo "channel=$CHANNEL"
echo "pkg=$PKG"
echo "upstream_repo=$UPSTREAM_REPO"
} >> "$GITHUB_OUTPUT"
- name: Checkout bucket repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install apt-ftparchive + gpg + wrangler
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends apt-utils gnupg jq
apt-ftparchive --version
gpg --version | head -1
# wrangler ships with Node — runner has it. Pin to a known-good version.
sudo npm install -g wrangler@4.95.0
wrangler --version
- name: Import GPG signing key
env:
GPG_PRIVATE_KEY: ${{ secrets.WHEELS_REPO_GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.WHEELS_REPO_GPG_PASSPHRASE }}
run: |
set -euo pipefail
if [ -z "${GPG_PRIVATE_KEY:-}" ]; then
echo "::error::WHEELS_REPO_GPG_PRIVATE_KEY is unset; cannot sign Release."
exit 1
fi
echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import
# import-ownertrust needs the 40-char fingerprint (fpr: field 10),
# NOT the 16-char key_id (sec: field 5). Using key_id emits a
# non-fatal "invalid fingerprint" warning that's noisy in CI logs.
FINGERPRINT=$(gpg --list-secret-keys --with-colons \
| awk -F: '/^fpr:/ { print $10; exit }')
KEY_ID=$(gpg --list-secret-keys --keyid-format=long --with-colons \
| awk -F: '/^sec:/ { print $5; exit }')
echo "Imported signing key: $KEY_ID (fpr $FINGERPRINT)"
echo "$FINGERPRINT:6:" | gpg --batch --yes --import-ownertrust
echo "GPG_KEY_ID=$KEY_ID" >> "$GITHUB_ENV"
- name: Sync existing pool from R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
run: |
set -euo pipefail
# List all R2 objects under pool/<channel>/ and download each.
# apt-ftparchive needs the actual .deb files locally so it can compute
# size + sha256 + read package metadata for the Packages index.
mkdir -p pool/${CHANNEL}
PREFIX="pool/${CHANNEL}/"
CURSOR=""
while :; do
URL="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects?prefix=${PREFIX}&per_page=1000"
[ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}"
RESP=$(curl -sS "$URL" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}")
echo "$RESP" | jq -r '.result[].key' | while IFS= read -r key; do
[ -z "$key" ] && continue
mkdir -p "$(dirname "$key")"
echo " pulling $key"
wrangler r2 object get "${R2_BUCKET}/${key}" --file="$key" --remote >/dev/null 2>&1
done
CURSOR=$(echo "$RESP" | jq -r '.result_info.cursor // empty')
[ -z "$CURSOR" ] && break
done
echo "Local pool/${CHANNEL}/ contents after sync:"
find pool/${CHANNEL}/ -type f | head -20
- name: Download new .deb from upstream Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.inputs.outputs.version }}
PKG: ${{ steps.inputs.outputs.pkg }}
UPSTREAM_REPO: ${{ steps.inputs.outputs.upstream_repo }}
run: |
set -euo pipefail
mkdir -p incoming
# GitHub Release URLs rewrite `~` to `.` at upload time. The version
# in client_payload uses SemVer hyphen (-snapshot.N); the on-URL form
# uses dot (.snapshot.N). Translate before fetch.
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
ASSET="${PKG}_${URL_VERSION}_amd64.deb"
TAG="v${VERSION}"
echo "Fetching ${ASSET} from ${UPSTREAM_REPO}@${TAG}"
gh release download "$TAG" \
--repo "$UPSTREAM_REPO" \
--pattern "$ASSET" \
--dir incoming/
ls -lh incoming/
- name: Slot new .deb into local pool
env:
VERSION: ${{ steps.inputs.outputs.version }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
PKG: ${{ steps.inputs.outputs.pkg }}
run: |
set -euo pipefail
# Canonical pool path uses ~-form (SemVer pre-release separator).
POOL_DIR="pool/${CHANNEL}/${PKG:0:1}/${PKG}"
POOL_FILE="${POOL_DIR}/${PKG}_${VERSION}_amd64.deb"
mkdir -p "$POOL_DIR"
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
mv "incoming/${PKG}_${URL_VERSION}_amd64.deb" "$POOL_FILE"
echo "Placed: $POOL_FILE ($(du -h "$POOL_FILE" | cut -f1))"
- name: Regenerate apt metadata + sign
env:
GPG_PASSPHRASE: ${{ secrets.WHEELS_REPO_GPG_PASSPHRASE }}
GPG_KEY_ID: ${{ env.GPG_KEY_ID }}
run: |
set -euo pipefail
chmod +x scripts/regenerate-apt-metadata.sh
./scripts/regenerate-apt-metadata.sh
- name: Upload pool + dists to R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
run: |
set -euo pipefail
upload_one() {
local local_path="$1"
local ct="$2"
local key="$local_path"
echo " uploading $key (ct=${ct})"
wrangler r2 object put "${R2_BUCKET}/${key}" --file="$local_path" --content-type="$ct" --remote >/dev/null 2>&1
}
# Upload pool files (.deb). Only the one we just added is new, but
# uploading all is idempotent (overwrites with same content).
find pool/${CHANNEL} -type f -name '*.deb' | while read -r f; do
upload_one "$f" "application/vnd.debian.binary-package"
done
# Upload regenerated dists tree. apt-ftparchive rewrites these on
# every run; upload all files in dists/ regardless of channel.
find dists -type f | while read -r f; do
# Pick a sensible content-type per file
case "$f" in
*.gz) ct="application/gzip" ;;
*.gpg) ct="application/pgp-signature" ;;
*InRelease|*Release) ct="text/plain" ;;
*Packages) ct="text/plain" ;;
*) ct="application/octet-stream" ;;
esac
upload_one "$f" "$ct"
done
echo "R2 upload complete for channel=${CHANNEL}."