Skip to content

Commit 39b678e

Browse files
feat(distribution): land Phase 2 native apt/yum repos via Cloudflare R2 (#2814)
* feat(distribution): land Phase 2 native apt/yum repos via Cloudflare R2 Closes #2605. apt.wheels.dev and yum.wheels.dev are now live, signed, and serving from Cloudflare R2 buckets (wheels-apt / wheels-yum) with custom apex-domain bindings. Verified end-to-end: curl -fsSL https://apt.wheels.dev/wheels.gpg \ | sudo tee /usr/share/keyrings/wheels.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable main" \ | sudo tee /etc/apt/sources.list.d/wheels.list sudo apt update && sudo apt install wheels sudo dnf config-manager --add-repo https://yum.wheels.dev/wheels.repo sudo dnf install wheels GPG: signed with key 6872 16C9 32B4 9F03 94E0 9AED 5D89 AF8F 9C9B 8CFB (`Wheels Distribution <hello@wheels.dev>`), expires 2031-05-25. InRelease and repomd.xml.asc both verify locally against the published wheels.gpg. Architecture deviation from the original Phase 2 sketch: served from Cloudflare R2 instead of Cloudflare Pages. Pages enforces a 25 MiB per-file limit; the .deb is 80 MB and the .rpm is 81 MB. R2 has no object-size limit and supports custom-domain serving, so the URL UX is identical to what the original sketch promised. Changes in this commit: tools/distribution-drafts/apt-repo/ - workflows/wheels-released.yml — R2-backed receiver (sync from R2 → add new .deb → regen metadata → upload changed tree to R2 via wrangler r2 object put). Replaces "git commit + push → Pages auto-deploy" with a direct R2 upload path. - README.md — Pages → R2 architecture note + updated operational prereqs (CLOUDFLARE_API_TOKEN with Workers R2 Storage:Edit). tools/distribution-drafts/yum-repo/ - workflows/wheels-released.yml — R2-backed receiver, parallel structure to apt. - scripts/regenerate-yum-metadata.sh — rpm --addsign now reads the passphrase from a chmod-600 temp file via %__gpg_sign_cmd override; the previous gpg.conf "pinentry-mode loopback" only tells gpg "don't pop TTY" but doesn't supply the passphrase, so CI runs were failing with "cannot open '/dev/tty'". - README.md — Pages → R2. tools/distribution-drafts/linux-packages/README.md - Phase 2 section now reflects R2-backed serving + the secrets checklist gains CLOUDFLARE_API_TOKEN per bucket repo. web/sites/guides/src/content/docs/v4-0-1-snapshot/start-here/installing.mdx web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/installation.mdx - Lead with `apt.wheels.dev` / `yum.wheels.dev` sources.list / .repo setup. The "Trust model during Phase 1" Aside is replaced with a "GPG signing" Aside that names the fingerprint and explains the verification chain. - One-off GH-Release download retained behind a Tip Aside for air-gapped use. - Linux upgrade section shrinks to `apt upgrade wheels` / `dnf upgrade wheels` (one command, no version pinning needed). Operational state landed in parallel (not in this commit, outside the repo): GPG key in 1Password at op://Wheels/wheels-linux-repo-signing/; R2 buckets wheels-apt + wheels-yum on the wheels.dev account with custom domains apt.wheels.dev / yum.wheels.dev; bucket-repo secrets (WHEELS_REPO_GPG_PRIVATE_KEY, WHEELS_REPO_GPG_PASSPHRASE, CLOUDFLARE_API_TOKEN) set on wheels-dev/apt-wheels and wheels-dev/yum-wheels. Still outstanding: LINUX_REPO_DISPATCH_TOKEN on wheels-dev/wheels. The release workflow's dispatch step skips silently when the secret is unset, so the next release ships without auto-publish but the backfill path (`gh workflow run wheels-released.yml --repo <bucket>`) works. Signed-off-by: Peter Amiri <peter@alurium.com> * docs: flip remaining install guides to native apt/yum sources Completes the doc flip for #2605. Earlier commit handled the v4-0-1-snapshot install pages; this one covers the four files the first pass missed: - v4-0-0/start-here/installing.mdx — Linux tab flipped from "download GH Release + apt install ./file.deb" to "add the signed apt.wheels.dev source + sudo apt install wheels". Same for Fedora/RHEL via yum.wheels.dev. Upgrade section shrinks from "re-run install" to "apt upgrade wheels". - v4-0-0/command-line-tools/installation.mdx — same flip on the CLI-installation page. Adds GPG-signing Aside naming the fingerprint, and keeps the GH-Release download snippet behind a "Need a one-off download?" Tip for air-gapped use. - v4-0-0/start-here/release-channels.mdx — Linux per-channel install snippets and the four "switching channels" snippets (stable→BE × {apt,dnf}, BE→stable × {apt,dnf}) flipped to use the native sources. The switch flow becomes 2 commands (add source + install) instead of 4 (curl + apt install). - v4-0-1-snapshot/start-here/release-channels.mdx — same flip as v4-0-0. Stale text removed: - "Apt and yum repos at apt.wheels.dev / yum.wheels.dev are coming" (×2) - "Native apt/yum repositories ... are on the v4.0.x roadmap" (×2) - "Trust model during Phase 1" Asides (×2) - All "until the native repos are live" hedges Both v4-0-0 and v4-0-1-snapshot docs now describe the live architecture accurately. The v4-0-0 docs aren't being rewritten — they're being corrected: the original sentence "Native apt/yum is coming" describes a future that's now in the past. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(docs): replace MDX-incompatible <https://...> autolinks with explicit link syntax The previous commit used GitHub-Flavored Markdown's "autolink" syntax (<https://apt.wheels.dev>) for bare URLs. MDX treats `<` as the start of a JSX tag and chokes: [@mdx-js/rollup] Unexpected character `/` (U+002F) before local name, expected a character that can start a name, such as a letter, `$`, or `_` file: web/sites/guides/src/content/docs/v4-0-0/command-line-tools/installation.mdx:54:73 That broke the `sites/guides` build, which broke the `Refresh visual baselines` workflow (and would have broken any other build-the-site job too). Fix: convert <URL> → [URL](URL) — the explicit Markdown link form — across all four mdx files I touched in the prior commit. Six total occurrences (apt.wheels.dev, yum.wheels.dev, two FINGERPRINT URLs × two file versions). Verified by grep: no <https://...> instances remain in web/sites/guides/src/content/docs/. Signed-off-by: Peter Amiri <peter@alurium.com> * fix(distribution): address Reviewer A/B consensus findings (round 1) Apply the converged findings from the Reviewer A/B exchange on #2814. - Replace `jq | while` and `find | while` piped loops with process substitution (`while ... done < <(...)`) in both apt and yum Sync + Upload steps. Pipe-RHS runs in a subshell where `set -e` in the parent does not see wrangler's non-zero exit, masking download/upload failures and producing trimmed indexes that silently overwrite Packages.gz / repomd.xml on R2. - Drop `>/dev/null 2>&1` from all wrangler r2 object get/put calls so a real failure surfaces in the CI log. - Add `jq -e '.success == true'` guard after the Cloudflare R2 list curl call in both Sync steps. When the token is invalid/expired or the API rate-limits, success=false comes back with an empty result array; without this check the sync succeeds with an empty local pool and metadata regen would overwrite the live index with a single-package tree. - Move the hardcoded `CLOUDFLARE_ACCOUNT_ID` literal out of the workflow env block to `${{ vars.CLOUDFLARE_ACCOUNT_ID }}`. Account IDs are not auth credentials, but keeping the workflow templates account-agnostic avoids handing a public-template harvester a ready-made API target. Each bucket repo sets the variable via `gh variable set CLOUDFLARE_ACCOUNT_ID --body '<id>' --repo ...`. Files touched: tools/distribution-drafts/apt-repo/workflows/wheels-released.yml tools/distribution-drafts/yum-repo/workflows/wheels-released.yml The PR-description claim that v4-0-0 docs are untouched (B's missed issue, finding 4) is a metadata correction, not a code change — it will be addressed in the PR description, not this commit. The CHANGELOG note (A's finding 4) was not part of the consensus (B refuted the CLAUDE.md-convention framing) and is skipped here. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(yum-script): clarify why rpm --addsign uses stdout-only suppression Reviewer A's round-2 nit: the `>/dev/null` on the rpm --addsign call inside regenerate-yum-metadata.sh is intentional (rpm's stdout is verbose progress chatter), but the rationale wasn't documented in the script. The round-1 review correctly noted that broadening it to `>/dev/null 2>&1` would silence stderr too and risk silent signing failures corrupting the repo. Add an inline comment that: - documents what the redirect actually suppresses (stdout progress chatter) - notes that stderr stays open so a signing failure still surfaces - explicitly warns a future editor not to broaden it to `2>&1` Signed-off-by: Peter Amiri <peter@alurium.com> * fix(web): authenticate sites/packages registry fetches to avoid 403 rate limit The packages.wheels.dev build (`sites/packages`) enumerates the package registry by calling the GitHub REST contents API at build time. Those fetches were unauthenticated, so they shared the 60 req/hour per-IP unauthenticated cap. Rapid CI activity on a single branch (PR #2814 saw ~6 pushes in an hour) exhausted the cap, and the build aborted mid-prerender with: Registry fetch failed: 403 rate limit exceeded — https://api.github.com/repos/wheels-dev/wheels-packages/contents/packages?ref=main That surfaced as a "Deploy packages" check failure unrelated to the PR's actual content. Fix: - registry.ts: send `Authorization: Bearer $GITHUB_TOKEN` (falls back to GH_TOKEN, then unauthenticated) on every registry fetch. Authenticated requests get 5000 req/hour instead of 60. Local/offline preview builds with no token keep working unauthenticated. - web-deploy.yml: pass `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` into the Build step env so the token is present in CI. Other site builds ignore the var. - registry.test.ts: add coverage for both the token-present (header sent) and token-absent (header omitted) paths. Verified locally: `pnpm --filter @wheels-dev/site-packages test` → 18 passed; `GITHUB_TOKEN=$(gh auth token) pnpm --filter @wheels-dev/site-packages build` → 7 pages built, registry fetch succeeded. Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent 0a6aa5e commit 39b678e

15 files changed

Lines changed: 582 additions & 392 deletions

File tree

.github/workflows/web-deploy.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ jobs:
5252

5353
- name: Build ${{ matrix.site }}
5454
working-directory: web
55+
env:
56+
# `sites/packages` calls the GitHub REST API at build time to
57+
# enumerate the package registry. Without auth that's capped at
58+
# 60 req/hour per runner IP, and rapid CI activity on a single
59+
# branch trips the limit (failure mode: 403 from the contents API,
60+
# build aborts mid-prerender). Passing GITHUB_TOKEN raises the cap
61+
# to 5000/hour. Other site builds ignore the var.
62+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5563
run: pnpm --filter @wheels-dev/site-${{ matrix.site }} build
5664

5765
- name: Deploy to Cloudflare Pages

tools/distribution-drafts/apt-repo/README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
# `apt-wheels` bucket repo template
22

33
This directory is the **template** for the standalone `wheels-dev/apt-wheels`
4-
repository that backs `https://apt.wheels.dev`. The bucket repo holds the static
5-
apt metadata tree plus the pooled `.deb` artifacts, and is auto-deployed to
6-
Cloudflare Pages on every push.
4+
repository that backs `https://apt.wheels.dev`. The bucket repo holds the
5+
**workflow + scripts + landing page + signing key**. The apt metadata tree
6+
(`dists/`) and the `.deb` pool (`pool/`) live in **Cloudflare R2** (bucket
7+
`wheels-apt`) and are served via R2's custom-domain feature.
8+
9+
> **Note on Pages vs R2:** The original Phase 2 design called for Cloudflare
10+
> Pages serving the bucket repo directly. Pages has a hard **25 MiB per-file**
11+
> limit; the `.deb` is ~80 MB. The architecture pivoted to R2 (no per-object
12+
> size limit) during initial rollout.
713
814
Copy these files into the new repo when it's created — they are designed to
915
work out of the box once the Phase 2 operational prerequisites (GPG key,
@@ -101,13 +107,15 @@ Before this bucket repo will function:
101107
into 1Password under `op://Wheels/wheels-linux-repo-signing/` (Wheels
102108
project vault on `my.1password.com`).
103109
Public key (ASCII-armored) overwrites `wheels.gpg` at the bucket-repo root.
104-
2. **Cloudflare Pages** — create a Pages project pointing at this repo, bind
105-
the apex domain `apt.wheels.dev`. The build command is empty (the repo
106-
*is* the static site); the output dir is `./`.
110+
2. **R2 bucket** — create a Cloudflare R2 bucket named `wheels-apt` and
111+
attach the `apt.wheels.dev` custom domain. R2 has no per-object size
112+
limit (unlike Pages' 25 MiB), which is why binaries are stored here.
107113
3. **CI secrets** (set on the bucket repo at
108114
`https://github.com/wheels-dev/apt-wheels/settings/secrets/actions`):
109115
- `WHEELS_REPO_GPG_PRIVATE_KEY` — ASCII-armored private key
110116
- `WHEELS_REPO_GPG_PASSPHRASE` — passphrase
117+
- `CLOUDFLARE_API_TOKEN` — token with `Workers R2 Storage:Edit` on the
118+
account that owns the `wheels-apt` bucket
111119
4. **Upstream dispatch** — the release workflow in `wheels-dev/wheels`
112120
fires a `repository_dispatch` (`wheels-released`) at this repo when a
113121
new `.deb` is published to the GitHub Release. The token used by the
Lines changed: 136 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
# Receiver workflow for `wheels-dev/apt-wheels`.
1+
# Receiver workflow for `wheels-dev/apt-wheels` (R2-backed architecture).
22
#
33
# Listens for `repository_dispatch` from `wheels-dev/wheels`'s release workflow,
4-
# downloads the new `.deb` asset from the upstream GitHub Release, slots it
5-
# into `pool/<channel>/w/<pkg>/`, regenerates the apt metadata via
6-
# `apt-ftparchive`, signs `Release` + `InRelease` with GPG, and commits the
7-
# whole tree back to this repo. Cloudflare Pages auto-deploys on push.
4+
# downloads the new `.deb` asset from the upstream GitHub Release, syncs the
5+
# existing apt repo state from R2 (so apt-ftparchive can see prior versions),
6+
# regenerates `Packages.gz` / `Release` / `InRelease` via `apt-ftparchive`,
7+
# signs with GPG, and uploads the changed tree to the `wheels-apt` R2 bucket
8+
# (which is served at https://apt.wheels.dev via R2 custom-domain).
9+
#
10+
# This repo no longer commits pool/ or dists/ — those are R2-resident only.
11+
# The repo's job is to hold the WORKFLOW + REGEN SCRIPT + LANDING + GPG KEY.
812
#
913
# Trigger payload contract (sender: wheels-dev/wheels release.yml):
1014
# event_type: "wheels-released"
1115
# client_payload:
12-
# version: "<x.y.z>" or "<x.y.z>-snapshot.<n>" # full SemVer, no leading v
13-
# channel: "stable" | "bleeding-edge" # routes into the pool
16+
# version: "<x.y.z>" or "<x.y.z>-snapshot.<n>"
17+
# channel: "stable" | "bleeding-edge"
1418
#
15-
# Manual dispatch is also supported via `workflow_dispatch` for backfill /
16-
# disaster-recovery (re-add a missing version without waiting for a new release).
19+
# Manual dispatch is supported via `workflow_dispatch` for backfill /
20+
# disaster-recovery.
1721

1822
name: Publish to apt.wheels.dev
1923

@@ -36,14 +40,23 @@ on:
3640
- bleeding-edge
3741

3842
permissions:
39-
contents: write # the workflow commits the regenerated metadata back to this repo
43+
contents: read
4044

4145
concurrency:
42-
# Serialize across both channels — apt-ftparchive scans the whole pool, so
43-
# parallel runs would race on Packages.gz / Release writes.
46+
# Serialize across channels — apt-ftparchive scans the whole pool, so
47+
# parallel runs would race on the regenerated Packages.gz / Release uploads.
4448
group: publish-apt
4549
cancel-in-progress: false
4650

51+
env:
52+
R2_BUCKET: wheels-apt
53+
# Cloudflare account ID is not a secret (no auth power on its own), but
54+
# keep it as a repo-level Actions variable so the workflow template stays
55+
# account-agnostic and so harvesting a public template doesn't hand an
56+
# attacker a ready-made API target. Set on the bucket repo:
57+
# gh variable set CLOUDFLARE_ACCOUNT_ID --body '<id>' --repo wheels-dev/apt-wheels
58+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
59+
4760
jobs:
4861
publish:
4962
name: Publish ${{ github.event.client_payload.version || inputs.version }} (${{ github.event.client_payload.channel || inputs.channel }})
@@ -52,52 +65,53 @@ jobs:
5265
steps:
5366
- name: Resolve inputs
5467
id: inputs
68+
env:
69+
CLIENT_VERSION: ${{ github.event.client_payload.version }}
70+
CLIENT_CHANNEL: ${{ github.event.client_payload.channel }}
71+
INPUT_VERSION: ${{ inputs.version }}
72+
INPUT_CHANNEL: ${{ inputs.channel }}
5573
run: |
74+
set -euo pipefail
5675
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
57-
VERSION="${{ github.event.client_payload.version }}"
58-
CHANNEL="${{ github.event.client_payload.channel }}"
76+
VERSION="$CLIENT_VERSION"
77+
CHANNEL="$CLIENT_CHANNEL"
5978
else
60-
VERSION="${{ inputs.version }}"
61-
CHANNEL="${{ inputs.channel }}"
79+
VERSION="$INPUT_VERSION"
80+
CHANNEL="$INPUT_CHANNEL"
6281
fi
6382
if [ -z "$VERSION" ] || [ -z "$CHANNEL" ]; then
6483
echo "::error::Both version and channel are required."
6584
exit 1
6685
fi
67-
# Reject "release-candidate" and other channels we don't yet plumb.
6886
case "$CHANNEL" in
6987
stable|bleeding-edge) ;;
7088
*) echo "::error::Unsupported channel: $CHANNEL"; exit 1 ;;
7189
esac
72-
# Map channel → package name (mirrors nfpm-wheels.yaml / nfpm-wheels-be.yaml).
7390
case "$CHANNEL" in
74-
stable) PKG="wheels" ;;
75-
bleeding-edge) PKG="wheels-be" ;;
91+
stable) PKG="wheels"; UPSTREAM_REPO="wheels-dev/wheels" ;;
92+
bleeding-edge) PKG="wheels-be"; UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;;
7693
esac
77-
# Map channel → upstream releases repo. Stable releases live in
78-
# wheels-dev/wheels; snapshot/BE releases live in wheels-dev/wheels-snapshots.
79-
case "$CHANNEL" in
80-
stable) UPSTREAM_REPO="wheels-dev/wheels" ;;
81-
bleeding-edge) UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;;
82-
esac
83-
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
84-
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
85-
echo "pkg=$PKG" >> "$GITHUB_OUTPUT"
86-
echo "upstream_repo=$UPSTREAM_REPO" >> "$GITHUB_OUTPUT"
94+
{
95+
echo "version=$VERSION"
96+
echo "channel=$CHANNEL"
97+
echo "pkg=$PKG"
98+
echo "upstream_repo=$UPSTREAM_REPO"
99+
} >> "$GITHUB_OUTPUT"
87100
88101
- name: Checkout bucket repo
89102
uses: actions/checkout@v4
90103
with:
91104
fetch-depth: 1
92-
# Persist the workflow token so the trailing `git push` works.
93-
persist-credentials: true
94105

95-
- name: Install apt-ftparchive + gpg
106+
- name: Install apt-ftparchive + gpg + wrangler
96107
run: |
97108
sudo apt-get update
98-
sudo apt-get install -y --no-install-recommends apt-utils gnupg
109+
sudo apt-get install -y --no-install-recommends apt-utils gnupg jq
99110
apt-ftparchive --version
100111
gpg --version | head -1
112+
# wrangler ships with Node — runner has it. Pin to a known-good version.
113+
sudo npm install -g wrangler@4.95.0
114+
wrangler --version
101115
102116
- name: Import GPG signing key
103117
env:
@@ -110,11 +124,9 @@ jobs:
110124
exit 1
111125
fi
112126
echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import
113-
# Trust the imported key ultimately so subsequent sign operations don't prompt.
114-
# `import-ownertrust` expects the 40-char fingerprint (`fpr:` line, field 10),
115-
# NOT the 16-char key_id (`sec:` line, field 5). Using the key_id form emits
116-
# a "gpg: error in '[stdin]': invalid fingerprint" warning that's non-fatal
117-
# but noisy in CI logs.
127+
# import-ownertrust needs the 40-char fingerprint (fpr: field 10),
128+
# NOT the 16-char key_id (sec: field 5). Using key_id emits a
129+
# non-fatal "invalid fingerprint" warning that's noisy in CI logs.
118130
FINGERPRINT=$(gpg --list-secret-keys --with-colons \
119131
| awk -F: '/^fpr:/ { print $10; exit }')
120132
KEY_ID=$(gpg --list-secret-keys --keyid-format=long --with-colons \
@@ -123,19 +135,63 @@ jobs:
123135
echo "$FINGERPRINT:6:" | gpg --batch --yes --import-ownertrust
124136
echo "GPG_KEY_ID=$KEY_ID" >> "$GITHUB_ENV"
125137
126-
- name: Download .deb from upstream Release
138+
- name: Sync existing pool from R2
139+
env:
140+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
141+
CHANNEL: ${{ steps.inputs.outputs.channel }}
142+
run: |
143+
set -euo pipefail
144+
# List all R2 objects under pool/<channel>/ and download each.
145+
# apt-ftparchive needs the actual .deb files locally so it can compute
146+
# size + sha256 + read package metadata for the Packages index.
147+
mkdir -p pool/${CHANNEL}
148+
PREFIX="pool/${CHANNEL}/"
149+
CURSOR=""
150+
while :; do
151+
URL="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects?prefix=${PREFIX}&per_page=1000"
152+
[ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}"
153+
RESP=$(curl -sS "$URL" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}")
154+
# Guard against silent failures: when the token is invalid/expired
155+
# or the API rate-limits, success=false comes back with an empty
156+
# result array. Without this check, jq emits nothing, the loop
157+
# never runs, regen produces a single-package index, and the
158+
# upload step overwrites Packages.gz on R2 with that trimmed
159+
# index. Every apt update afterward sees the historical packages
160+
# vanish from the repo. Fail loud here instead.
161+
if ! echo "$RESP" | jq -e '.success == true' >/dev/null; then
162+
echo "::error::Cloudflare R2 list API returned success=false (or invalid JSON). Response:"
163+
echo "$RESP"
164+
exit 1
165+
fi
166+
# Process substitution (not a pipe) so the while body runs in the
167+
# parent shell — pipe-RHS runs in a subshell where set -e in the
168+
# parent does not see wrangler's non-zero exit, masking download
169+
# failures. Same reasoning: wrangler's stderr is NOT suppressed
170+
# so a real failure surfaces in the CI log.
171+
while IFS= read -r key; do
172+
[ -z "$key" ] && continue
173+
mkdir -p "$(dirname "$key")"
174+
echo " pulling $key"
175+
wrangler r2 object get "${R2_BUCKET}/${key}" --file="$key" --remote
176+
done < <(echo "$RESP" | jq -r '.result[].key')
177+
CURSOR=$(echo "$RESP" | jq -r '.result_info.cursor // empty')
178+
[ -z "$CURSOR" ] && break
179+
done
180+
echo "Local pool/${CHANNEL}/ contents after sync:"
181+
find pool/${CHANNEL}/ -type f | head -20
182+
183+
- name: Download new .deb from upstream Release
127184
env:
128185
GH_TOKEN: ${{ github.token }}
129186
VERSION: ${{ steps.inputs.outputs.version }}
130-
CHANNEL: ${{ steps.inputs.outputs.channel }}
131187
PKG: ${{ steps.inputs.outputs.pkg }}
132188
UPSTREAM_REPO: ${{ steps.inputs.outputs.upstream_repo }}
133189
run: |
134190
set -euo pipefail
135191
mkdir -p incoming
136192
# GitHub Release URLs rewrite `~` to `.` at upload time. The version
137-
# passed in `client_payload.version` uses `-snapshot.N` (SemVer), but
138-
# the on-URL filename uses `.snapshot.N`. Translate.
193+
# in client_payload uses SemVer hyphen (-snapshot.N); the on-URL form
194+
# uses dot (.snapshot.N). Translate before fetch.
139195
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
140196
ASSET="${PKG}_${URL_VERSION}_amd64.deb"
141197
TAG="v${VERSION}"
@@ -144,25 +200,22 @@ jobs:
144200
--repo "$UPSTREAM_REPO" \
145201
--pattern "$ASSET" \
146202
--dir incoming/
147-
ls -l incoming/
203+
ls -lh incoming/
148204
149-
- name: Slot .deb into pool/
205+
- name: Slot new .deb into local pool
150206
env:
151207
VERSION: ${{ steps.inputs.outputs.version }}
152208
CHANNEL: ${{ steps.inputs.outputs.channel }}
153209
PKG: ${{ steps.inputs.outputs.pkg }}
154210
run: |
155211
set -euo pipefail
156-
# Canonical pool form uses `~` (SemVer pre-release separator).
212+
# Canonical pool path uses ~-form (SemVer pre-release separator).
157213
POOL_DIR="pool/${CHANNEL}/${PKG:0:1}/${PKG}"
158214
POOL_FILE="${POOL_DIR}/${PKG}_${VERSION}_amd64.deb"
159215
mkdir -p "$POOL_DIR"
160-
# The downloaded file uses `.` in the snapshot separator; rename to `~`.
161216
URL_VERSION="${VERSION/-snapshot./.snapshot.}"
162217
mv "incoming/${PKG}_${URL_VERSION}_amd64.deb" "$POOL_FILE"
163-
echo "Placed: $POOL_FILE"
164-
# Idempotency: if the pool already had this version, the mv overwrote
165-
# it. Re-runs of the same dispatch are safe (last-writer-wins).
218+
echo "Placed: $POOL_FILE ($(du -h "$POOL_FILE" | cut -f1))"
166219
167220
- name: Regenerate apt metadata + sign
168221
env:
@@ -173,19 +226,40 @@ jobs:
173226
chmod +x scripts/regenerate-apt-metadata.sh
174227
./scripts/regenerate-apt-metadata.sh
175228
176-
- name: Commit + push
229+
- name: Upload pool + dists to R2
177230
env:
178-
VERSION: ${{ steps.inputs.outputs.version }}
231+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
179232
CHANNEL: ${{ steps.inputs.outputs.channel }}
180-
PKG: ${{ steps.inputs.outputs.pkg }}
181233
run: |
182234
set -euo pipefail
183-
git config user.name "wheels-release-bot"
184-
git config user.email "hello@wheels.dev"
185-
git add pool/ dists/
186-
if git diff --cached --quiet; then
187-
echo "No changes — dispatch must have been a no-op (same version re-published?)."
188-
exit 0
189-
fi
190-
git commit -m "publish: ${PKG} ${VERSION} (${CHANNEL})"
191-
git push origin HEAD:main
235+
upload_one() {
236+
local local_path="$1"
237+
local ct="$2"
238+
local key="$local_path"
239+
echo " uploading $key (ct=${ct})"
240+
# Do not suppress wrangler output — a silent upload failure would
241+
# leave R2 with a stale Packages.gz / Release while the job exits
242+
# green.
243+
wrangler r2 object put "${R2_BUCKET}/${key}" --file="$local_path" --content-type="$ct" --remote
244+
}
245+
# Upload pool files (.deb). Only the one we just added is new, but
246+
# uploading all is idempotent (overwrites with same content).
247+
# Process substitution (not pipe) so set -e fires on upload_one
248+
# failures — pipe-RHS subshell semantics would swallow them.
249+
while read -r f; do
250+
upload_one "$f" "application/vnd.debian.binary-package"
251+
done < <(find pool/${CHANNEL} -type f -name '*.deb')
252+
# Upload regenerated dists tree. apt-ftparchive rewrites these on
253+
# every run; upload all files in dists/ regardless of channel.
254+
while read -r f; do
255+
# Pick a sensible content-type per file
256+
case "$f" in
257+
*.gz) ct="application/gzip" ;;
258+
*.gpg) ct="application/pgp-signature" ;;
259+
*InRelease|*Release) ct="text/plain" ;;
260+
*Packages) ct="text/plain" ;;
261+
*) ct="application/octet-stream" ;;
262+
esac
263+
upload_one "$f" "$ct"
264+
done < <(find dists -type f)
265+
echo "R2 upload complete for channel=${CHANNEL}."

0 commit comments

Comments
 (0)