wheels-released #295
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/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 |