wheels-released #99
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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}." |