Skip to content

wheels-released

wheels-released #295

# Receiver workflow for `wheels-dev/yum-wheels` (R2-backed architecture).
#
# Listens for `repository_dispatch` from `wheels-dev/wheels`'s release workflow,
# downloads the new `.rpm` asset from the upstream GitHub Release, syncs the
# existing yum tree from R2 (so createrepo_c can re-scan prior versions),
# signs each .rpm with `rpm --addsign`, regenerates the channel's repodata
# via `createrepo_c`, gpg-signs `repomd.xml`, and uploads the changes to the
# `wheels-yum` R2 bucket (served at https://yum.wheels.dev).
#
# This repo no longer commits stable/ or bleeding-edge/ — those are R2-only.
#
# 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"
name: Publish to yum.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:
group: publish-yum
cancel-in-progress: false
env:
R2_BUCKET: wheels-yum
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 createrepo_c + gpg + rpm + wrangler
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends createrepo-c gnupg rpm jq
createrepo_c --version
gpg --version | head -1
rpm --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 repomd.xml."
exit 1
fi
echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import
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 channel from R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
run: |
set -euo pipefail
# createrepo_c rescans <channel>/packages/ on each run, so we need
# ALL previously-published .rpm files locally to keep them in the
# repodata. Pull the whole channel down before adding the new one.
mkdir -p "${CHANNEL}/packages"
PREFIX="${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 ${CHANNEL}/ contents after sync:"
find "${CHANNEL}/" -type f | head -20
- name: Download new .rpm 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
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
ASSET="${PKG}-${URL_VERSION}.noarch.rpm"
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 .rpm into local channel
env:
VERSION: ${{ steps.inputs.outputs.version }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
PKG: ${{ steps.inputs.outputs.pkg }}
run: |
set -euo pipefail
PKG_DIR="${CHANNEL}/packages"
mkdir -p "$PKG_DIR"
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
mv "incoming/${PKG}-${URL_VERSION}.noarch.rpm" \
"${PKG_DIR}/${PKG}-${VERSION}.noarch.rpm"
echo "Placed: ${PKG_DIR}/${PKG}-${VERSION}.noarch.rpm"
- name: Regenerate yum metadata + sign
env:
GPG_PASSPHRASE: ${{ secrets.WHEELS_REPO_GPG_PASSPHRASE }}
GPG_KEY_ID: ${{ env.GPG_KEY_ID }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
run: |
set -euo pipefail
chmod +x scripts/regenerate-yum-metadata.sh
./scripts/regenerate-yum-metadata.sh
- name: Upload channel tree to R2
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CHANNEL: ${{ steps.inputs.outputs.channel }}
run: |
set -euo pipefail
# Cache-Control is the fix for the stale-metadata failure (the yum
# analogue of apt #3218): Cloudflare auto-caches by file extension, so
# a published repomd.xml / *-primary.xml.gz can be edge-cached and kept
# being served after the next publish rewrites it, leaving dnf with a
# repomd that points at checksums the served metadata no longer has
# ("Status code: 404"/"checksum mismatch"). repodata changes every
# publish, so it must never be edge-cached: tag it `no-store`. The
# .rpm packages are immutable (version-stamped) -> long immutable cache.
RPM_CC="public, max-age=31536000, immutable"
META_CC="no-store, max-age=0"
upload_one() {
local local_path="$1" ct="$2" cc="$3" key="$1"
echo " uploading $key (ct=${ct}; cache-control=${cc})"
wrangler r2 object put "${R2_BUCKET}/${key}" \
--file="$local_path" --content-type="$ct" --cache-control="$cc" --remote >/dev/null 2>&1
}
# Collect absolute URLs we touch so the purge step can evict legacy
# cached copies (objects cached before no-store linger until old TTL).
: > /tmp/purge-urls.txt
# Walk the channel tree and upload every file. createrepo_c is
# idempotent — files unchanged from prior runs overwrite with the same
# bytes. New .rpm + regenerated repodata/ are the only meaningful diffs.
find "${CHANNEL}" -type f | while read -r f; do
case "$f" in
*.rpm) ct="application/x-rpm"; cc="$RPM_CC" ;;
*.xml) ct="application/xml"; cc="$META_CC" ;;
*.xml.gz) ct="application/gzip"; cc="$META_CC" ;;
*.asc) ct="application/pgp-signature"; cc="$META_CC" ;;
*.key) ct="application/pgp-keys"; cc="$META_CC" ;;
*) ct="application/octet-stream"; cc="$META_CC" ;;
esac
upload_one "$f" "$ct" "$cc"
echo "https://yum.wheels.dev/$f" >> /tmp/purge-urls.txt
done
echo "R2 upload complete for channel=${CHANNEL}."
- name: Purge Cloudflare edge cache for the published files
env:
# Cache purge is a SEPARATE Cloudflare permission (Zone.Cache-Purge)
# from R2 (account-scoped). The shared CLOUDFLARE_API_TOKEN only has
# R2 + Zone:Read, so purge with it returns 401. Use a dedicated
# CF_PURGE_TOKEN (Zone.Cache-Purge on the wheels.dev zone) if one is
# configured; otherwise skip — `no-store` on the repodata (set in the
# upload step) already guarantees the edge never serves stale metadata,
# so the purge is an optional latency optimization, not correctness.
CF_PURGE_TOKEN: ${{ secrets.CF_PURGE_TOKEN }}
run: |
set -uo pipefail
[ -s /tmp/purge-urls.txt ] || { echo "No URLs to purge."; exit 0; }
if [ -z "${CF_PURGE_TOKEN:-}" ]; then
echo "No CF_PURGE_TOKEN configured — skipping purge. (no-store keeps every publish fresh at the edge; add a Zone.Cache-Purge token as CF_PURGE_TOKEN to evict legacy entries immediately.)"
exit 0
fi
ZONE_ID="$(curl -fsS "https://api.cloudflare.com/client/v4/zones?name=wheels.dev" \
-H "Authorization: Bearer ${CF_PURGE_TOKEN}" | jq -r '.result[0].id // empty')"
if [ -z "$ZONE_ID" ]; then
echo "::warning::CF_PURGE_TOKEN is set but the wheels.dev zone could not be resolved (token scope?). Relying on no-store."
exit 0
fi
mapfile -t urls < /tmp/purge-urls.txt
i=0
while [ "$i" -lt "${#urls[@]}" ]; do
batch=("${urls[@]:i:30}")
payload="$(printf '%s\n' "${batch[@]}" | jq -R . | jq -s '{files: .}')"
resp="$(curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_PURGE_TOKEN}" \
-H "Content-Type: application/json" --data "$payload" || true)"
if [ "$(jq -r '.success // false' <<<"$resp")" = "true" ]; then
echo "Purged ${#batch[@]} url(s)."
else
echo "::warning::CF_PURGE_TOKEN purge did not report success: $(jq -rc '.errors // .' <<<"$resp" 2>/dev/null || echo "$resp")"
fi
i=$((i + 30))
done