Skip to content

Commit 4fb076e

Browse files
aaddrickclaude
andauthored
feat: APT/DNF Worker scaffolding (#498)
* feat: APT/DNF Worker scaffolding (#493) Adds the implementation scaffolding for the Cloudflare Worker that fronts the APT/DNF repo, per docs/worker-apt-plan.md. New files: - worker/src/worker.js: redirects /pool/.../*.deb and /rpm/*/*.rpm to GitHub Release assets via 302; passes metadata through to the gh-pages origin - worker/wrangler.toml: bound to pkg-staging.claude-desktop-debian.dev initially; Phase 4a switches to pkg.claude-desktop-debian.dev - .github/workflows/deploy-worker.yml: deploys Worker on worker/** push, post-deploy probe verifies route bound + Worker responding - .github/workflows/apt-repo-heartbeat.yml: daily cron, deb+rpm matrix, walks ordered redirect chain + size match against Releases asset, opens format-specific tracking issue on failure (auto-close on recovery), gates on Worker liveness (skips silently before Phase 4a) Modified: - .github/workflows/ci.yml: gated strip step + ordered-chain smoke test added to update-apt-repo and update-dnf-repo; the destructive strip only fires when the production Worker probe succeeds, so this PR can land before Phase 4a without affecting current behavior - docs/worker-apt-plan.md: bake in real domain values, mark Decisions table entries as concrete, fix Cloudflare API token permissions list (current names: Workers Scripts Edit, Account Settings Read, Workers Routes Edit; previous "Zone:Zone:Read" name no longer matches the dropdown) Pre-Phase-4a behavior: the strip step's liveness probe targets the production hostname which doesn't exist yet, so it always skips and .debs/.rpms are pushed to gh-pages exactly as today. Smoke tests skip on the same gate. Heartbeat workflow's gate skips before the Worker is live. Nothing destructive happens until Phase 4a explicitly cuts the Worker over to production. Co-Authored-By: Claude <claude@anthropic.com> * refactor: simplify worker scaffolding per cdd-code-simplifier review - worker.js: use named capture group `asset` instead of opaque `m[1]` positional reference; inline single-use `tagFor()` helper; demote unused `arch` capture to non-capturing group. - ci.yml: hoist `WORKER_DOMAIN` from per-step env to job-level env in both `update-apt-repo` and `update-dnf-repo` (matches the pattern already used in `apt-repo-heartbeat.yml`). - apt-repo-heartbeat.yml: use github-script's native `context.serverUrl` / `context.runId` instead of reconstructing from process.env; spread `...context.repo` instead of repeating owner/repo on every API call; destructure `{ data: open }` to flatten `open.data` references. All changes preserve behaviour. The contrarian-fix mechanisms (positive Worker liveness probe gating the strip step, hop-by-hop ordered chain walk in smoke tests) are unchanged. APT/DNF strip + smoke pairs remain in-place per reviewer-readability preference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 937b1cc commit 4fb076e

6 files changed

Lines changed: 487 additions & 24 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
name: APT/DNF Repo Heartbeat
2+
3+
# Walks the published .deb and .rpm URLs through the full
4+
# Pages 301 → Worker 302 → Releases 302 → CDN 200 chain daily,
5+
# asserts ordered hops, asserts size match against the Releases
6+
# asset, and opens a tracking issue (with a format-specific label)
7+
# on failure. Auto-closes the issue when the format recovers.
8+
#
9+
# Pre-Phase-4a: the gate step skips gracefully when the production
10+
# Worker isn't live yet. Once Phase 4a is done, the gate passes
11+
# and the full chain is exercised every day.
12+
13+
on:
14+
schedule:
15+
- cron: '0 12 * * *' # daily noon UTC
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
issues: write
21+
22+
jobs:
23+
ping:
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
format: [deb, rpm]
28+
runs-on: ubuntu-latest
29+
env:
30+
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
31+
GH_TOKEN: ${{ github.token }}
32+
33+
steps:
34+
- name: Skip if Worker not live yet
35+
id: gate
36+
run: |
37+
if curl -fsI --max-time 10 \
38+
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
39+
echo "live=true" >> "$GITHUB_OUTPUT"
40+
echo "Worker live; running heartbeat."
41+
else
42+
echo "live=false" >> "$GITHUB_OUTPUT"
43+
echo "Worker not live; heartbeat skipping (expected before Phase 4a)."
44+
fi
45+
46+
- name: Resolve latest release for ${{ matrix.format }}
47+
if: steps.gate.outputs.live == 'true'
48+
id: latest
49+
run: |
50+
tag=$(gh release list --limit 1 --json tagName \
51+
--jq '.[0].tagName' \
52+
--repo aaddrick/claude-desktop-debian)
53+
repoVer="${tag#v}"; repoVer="${repoVer%+claude*}"
54+
claudeVer="${tag#*+claude}"
55+
if [[ "${{ matrix.format }}" == "deb" ]]; then
56+
asset="claude-desktop_${claudeVer}-${repoVer}_amd64.deb"
57+
url="https://aaddrick.github.io/claude-desktop-debian/pool/main/c/claude-desktop/${asset}"
58+
else
59+
asset="claude-desktop-${claudeVer}-${repoVer}-1.x86_64.rpm"
60+
url="https://aaddrick.github.io/claude-desktop-debian/rpm/x86_64/${asset}"
61+
fi
62+
{
63+
echo "tag=${tag}"
64+
echo "asset=${asset}"
65+
echo "url=${url}"
66+
} >> "$GITHUB_OUTPUT"
67+
68+
- name: Validate ordered chain + fetch + size match
69+
if: steps.gate.outputs.live == 'true'
70+
env:
71+
ASSET: ${{ steps.latest.outputs.asset }}
72+
URL: ${{ steps.latest.outputs.url }}
73+
TAG: ${{ steps.latest.outputs.tag }}
74+
FORMAT: ${{ matrix.format }}
75+
run: |
76+
set -euo pipefail
77+
78+
# Wait for propagation; fail after 5 min instead of cargo-cult sleep
79+
deadline=$((SECONDS + 300))
80+
until curl -fsI --max-time 10 "$URL" -o /dev/null; do
81+
if [[ $SECONDS -gt $deadline ]]; then
82+
echo "::error::Reachability timeout for ${URL}"
83+
exit 1
84+
fi
85+
sleep 10
86+
done
87+
88+
# Walk redirect chain hop-by-hop, asserting each hop's pattern in order
89+
expected_hops=(
90+
"https://${WORKER_DOMAIN}/"
91+
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/"
92+
"https://objects\\.githubusercontent\\.com/"
93+
)
94+
url="$URL"
95+
for i in "${!expected_hops[@]}"; do
96+
hop_status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
97+
redirect_url=$(curl -s -o /dev/null -w '%{redirect_url}' "$url")
98+
echo "Hop ${i}: ${hop_status} ${url} -> ${redirect_url}"
99+
if [[ ! "$hop_status" =~ ^30[12]$ ]]; then
100+
echo "::error::Hop ${i}: expected 301/302, got ${hop_status}"
101+
exit 1
102+
fi
103+
if [[ ! "$redirect_url" =~ ^${expected_hops[$i]} ]]; then
104+
echo "::error::Hop ${i} mismatch:"
105+
echo "::error:: expected: ${expected_hops[$i]}"
106+
echo "::error:: got: ${redirect_url}"
107+
exit 1
108+
fi
109+
url="$redirect_url"
110+
done
111+
112+
# Fetch the asset and validate its format
113+
curl -fsSL -o "/tmp/${ASSET}" "$URL"
114+
115+
if [[ "$FORMAT" == "deb" ]]; then
116+
if ! file "/tmp/${ASSET}" | grep -q 'Debian binary package'; then
117+
echo "::error::Fetched file is not a valid Debian package"
118+
exit 1
119+
fi
120+
else
121+
sudo apt-get update >/dev/null
122+
sudo apt-get install -y rpm >/dev/null
123+
if ! rpm -qpi "/tmp/${ASSET}" >/dev/null 2>&1; then
124+
echo "::error::Fetched file is not a valid RPM"
125+
exit 1
126+
fi
127+
fi
128+
129+
# Size match against the Releases asset
130+
asset_size=$(gh release view "$TAG" \
131+
--repo aaddrick/claude-desktop-debian \
132+
--json assets \
133+
--jq ".assets[] | select(.name == \"${ASSET}\") | .size")
134+
local_size=$(stat -c %s "/tmp/${ASSET}")
135+
if [[ "$asset_size" != "$local_size" ]]; then
136+
echo "::error::Size mismatch: local ${local_size} vs Releases ${asset_size}"
137+
exit 1
138+
fi
139+
140+
echo "Heartbeat passed: chain validated, file matches Releases asset."
141+
142+
- name: Open or update failure issue
143+
if: failure() && steps.gate.outputs.live == 'true'
144+
uses: actions/github-script@v7
145+
env:
146+
FORMAT: ${{ matrix.format }}
147+
with:
148+
script: |
149+
const fmt = process.env.FORMAT;
150+
const label = `heartbeat-failure-${fmt}`;
151+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
152+
const body = `Heartbeat failed for \`${fmt}\` at ${new Date().toISOString()}.\nRun: ${runUrl}`;
153+
const { data: open } = await github.rest.issues.listForRepo({
154+
...context.repo,
155+
labels: label,
156+
state: 'open',
157+
});
158+
if (open.length === 0) {
159+
await github.rest.issues.create({
160+
...context.repo,
161+
title: `APT/DNF repo heartbeat failing (${fmt})`,
162+
body,
163+
labels: [label],
164+
});
165+
} else {
166+
await github.rest.issues.createComment({
167+
...context.repo,
168+
issue_number: open[0].number,
169+
body,
170+
});
171+
}
172+
173+
- name: Auto-close failure issue on recovery
174+
if: success() && steps.gate.outputs.live == 'true'
175+
uses: actions/github-script@v7
176+
env:
177+
FORMAT: ${{ matrix.format }}
178+
with:
179+
script: |
180+
const fmt = process.env.FORMAT;
181+
const label = `heartbeat-failure-${fmt}`;
182+
const { data: open } = await github.rest.issues.listForRepo({
183+
...context.repo,
184+
labels: label,
185+
state: 'open',
186+
});
187+
for (const issue of open) {
188+
await github.rest.issues.createComment({
189+
...context.repo,
190+
issue_number: issue.number,
191+
body: `Heartbeat for \`${fmt}\` recovered at ${new Date().toISOString()}; auto-closing.`,
192+
});
193+
await github.rest.issues.update({
194+
...context.repo,
195+
issue_number: issue.number,
196+
state: 'closed',
197+
});
198+
}

.github/workflows/ci.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,8 @@ jobs:
405405
runs-on: ubuntu-latest
406406
permissions:
407407
contents: write
408+
env:
409+
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
408410

409411
steps:
410412
- name: Checkout gh-pages branch
@@ -453,6 +455,24 @@ jobs:
453455
reprepro --section utils --priority optional includedeb stable "$deb"
454456
done
455457
458+
- name: Strip binaries from pool (gated on Worker liveness)
459+
working-directory: apt-repo
460+
run: |
461+
# The Worker on WORKER_DOMAIN serves /pool/.../*.deb requests by
462+
# 302-redirecting to GitHub Release assets. When it's live we strip
463+
# binaries from the gh-pages tree (the metadata's Filename: field
464+
# still references pool paths; the Worker intercepts).
465+
# When the Worker isn't live (pre-Phase-4a, outage, misconfiguration)
466+
# the strip is skipped to avoid serving 404s for binary fetches.
467+
probe_url="https://${WORKER_DOMAIN}/dists/stable/InRelease"
468+
if curl -fsI --max-time 10 "$probe_url" >/dev/null; then
469+
echo "Worker live at ${WORKER_DOMAIN}; stripping binaries from pool"
470+
find pool -type f -name '*.deb' -delete
471+
else
472+
echo "Worker not responding at ${WORKER_DOMAIN}; preserving .debs in pool"
473+
echo "(expected before Phase 4a; after that, an error worth investigating)"
474+
fi
475+
456476
- name: Commit and push changes
457477
working-directory: apt-repo
458478
run: |
@@ -472,13 +492,75 @@ jobs:
472492
sleep "$wait_time"
473493
done
474494
495+
- name: Smoke test published deb (ordered chain + size)
496+
env:
497+
GH_TOKEN: ${{ github.token }}
498+
TAG: ${{ github.ref_name }}
499+
run: |
500+
set -euo pipefail
501+
if ! curl -fsI --max-time 10 \
502+
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
503+
echo "Worker not live; skipping smoke test (expected before Phase 4a)"
504+
exit 0
505+
fi
506+
507+
# Parse versions from tag (e.g., v2.0.2+claude1.3883.0)
508+
repoVer="${TAG#v}"; repoVer="${repoVer%+claude*}"
509+
claudeVer="${TAG#*+claude}"
510+
deb_name="claude-desktop_${claudeVer}-${repoVer}_amd64.deb"
511+
deb_url="https://aaddrick.github.io/claude-desktop-debian/pool/main/c/claude-desktop/${deb_name}"
512+
513+
# Wait for propagation
514+
deadline=$((SECONDS + 300))
515+
until curl -fsI --max-time 10 "$deb_url" -o /dev/null; do
516+
[[ $SECONDS -gt $deadline ]] \
517+
&& { echo "::error::Reachability timeout for ${deb_url}"; exit 1; }
518+
sleep 10
519+
done
520+
521+
# Walk redirect chain hop-by-hop
522+
expected_hops=(
523+
"https://${WORKER_DOMAIN}/"
524+
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/v${repoVer}\\+claude${claudeVer}/"
525+
"https://objects\\.githubusercontent\\.com/"
526+
)
527+
url="$deb_url"
528+
for i in "${!expected_hops[@]}"; do
529+
hop_status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
530+
redirect_url=$(curl -s -o /dev/null -w '%{redirect_url}' "$url")
531+
echo "Hop ${i}: ${hop_status} ${url} -> ${redirect_url}"
532+
[[ "$hop_status" =~ ^30[12]$ ]] \
533+
|| { echo "::error::Hop ${i} expected 301/302, got ${hop_status}"; exit 1; }
534+
[[ "$redirect_url" =~ ^${expected_hops[$i]} ]] \
535+
|| { echo "::error::Hop ${i} mismatch: expected ${expected_hops[$i]}, got ${redirect_url}"; exit 1; }
536+
url="$redirect_url"
537+
done
538+
539+
# Fetch and validate
540+
curl -fsSL -o /tmp/smoke.deb "$deb_url"
541+
file /tmp/smoke.deb | grep -q 'Debian binary package' \
542+
|| { echo "::error::Not a valid Debian package"; exit 1; }
543+
544+
# Size match against the Releases asset
545+
asset_size=$(gh release view "$TAG" \
546+
--repo aaddrick/claude-desktop-debian \
547+
--json assets \
548+
--jq ".assets[] | select(.name == \"${deb_name}\") | .size")
549+
local_size=$(stat -c %s /tmp/smoke.deb)
550+
[[ "$asset_size" == "$local_size" ]] \
551+
|| { echo "::error::Size mismatch: ${local_size} vs ${asset_size}"; exit 1; }
552+
553+
echo "APT smoke test passed: chain validated, file matches Releases asset"
554+
475555
update-dnf-repo:
476556
name: Update DNF Repository
477557
if: startsWith(github.ref, 'refs/tags/v')
478558
needs: [release, update-apt-repo]
479559
runs-on: ubuntu-latest
480560
permissions:
481561
contents: write
562+
env:
563+
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
482564

483565
steps:
484566
- name: Checkout gh-pages branch
@@ -570,6 +652,21 @@ jobs:
570652
'gpgkey=https://aaddrick.github.io/claude-desktop-debian/KEY.gpg' \
571653
> rpm/claude-desktop.repo
572654
655+
- name: Strip RPMs from pool (gated on Worker liveness)
656+
working-directory: dnf-repo
657+
run: |
658+
# Mirror of the APT-side strip. Repodata (signed) stays; the .rpm
659+
# binaries themselves are deleted because the Worker 302-redirects
660+
# /rpm/<arch>/*.rpm requests to GitHub Release assets.
661+
probe_url="https://${WORKER_DOMAIN}/dists/stable/InRelease"
662+
if curl -fsI --max-time 10 "$probe_url" >/dev/null; then
663+
echo "Worker live; stripping RPMs from pool (repodata + signatures retained)"
664+
find rpm -type f -name '*.rpm' -delete
665+
else
666+
echo "Worker not responding; preserving .rpms in pool"
667+
echo "(expected before Phase 4a; after that, an error worth investigating)"
668+
fi
669+
573670
- name: Commit and push changes
574671
working-directory: dnf-repo
575672
run: |
@@ -589,6 +686,61 @@ jobs:
589686
sleep "$wait_time"
590687
done
591688
689+
- name: Smoke test published rpm (ordered chain + size)
690+
env:
691+
GH_TOKEN: ${{ github.token }}
692+
TAG: ${{ github.ref_name }}
693+
run: |
694+
set -euo pipefail
695+
if ! curl -fsI --max-time 10 \
696+
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
697+
echo "Worker not live; skipping smoke test (expected before Phase 4a)"
698+
exit 0
699+
fi
700+
701+
repoVer="${TAG#v}"; repoVer="${repoVer%+claude*}"
702+
claudeVer="${TAG#*+claude}"
703+
rpm_name="claude-desktop-${claudeVer}-${repoVer}-1.x86_64.rpm"
704+
rpm_url="https://aaddrick.github.io/claude-desktop-debian/rpm/x86_64/${rpm_name}"
705+
706+
deadline=$((SECONDS + 300))
707+
until curl -fsI --max-time 10 "$rpm_url" -o /dev/null; do
708+
[[ $SECONDS -gt $deadline ]] \
709+
&& { echo "::error::Reachability timeout for ${rpm_url}"; exit 1; }
710+
sleep 10
711+
done
712+
713+
expected_hops=(
714+
"https://${WORKER_DOMAIN}/"
715+
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/v${repoVer}\\+claude${claudeVer}/"
716+
"https://objects\\.githubusercontent\\.com/"
717+
)
718+
url="$rpm_url"
719+
for i in "${!expected_hops[@]}"; do
720+
hop_status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
721+
redirect_url=$(curl -s -o /dev/null -w '%{redirect_url}' "$url")
722+
echo "Hop ${i}: ${hop_status} ${url} -> ${redirect_url}"
723+
[[ "$hop_status" =~ ^30[12]$ ]] \
724+
|| { echo "::error::Hop ${i} expected 301/302, got ${hop_status}"; exit 1; }
725+
[[ "$redirect_url" =~ ^${expected_hops[$i]} ]] \
726+
|| { echo "::error::Hop ${i} mismatch: expected ${expected_hops[$i]}, got ${redirect_url}"; exit 1; }
727+
url="$redirect_url"
728+
done
729+
730+
curl -fsSL -o /tmp/smoke.rpm "$rpm_url"
731+
rpm -qpi /tmp/smoke.rpm >/dev/null \
732+
|| { echo "::error::Not a valid RPM"; exit 1; }
733+
734+
asset_size=$(gh release view "$TAG" \
735+
--repo aaddrick/claude-desktop-debian \
736+
--json assets \
737+
--jq ".assets[] | select(.name == \"${rpm_name}\") | .size")
738+
local_size=$(stat -c %s /tmp/smoke.rpm)
739+
[[ "$asset_size" == "$local_size" ]] \
740+
|| { echo "::error::Size mismatch: ${local_size} vs ${asset_size}"; exit 1; }
741+
742+
echo "DNF smoke test passed: chain validated, file matches Releases asset"
743+
592744
update-aur-repo:
593745
name: Update AUR Package
594746
if: startsWith(github.ref, 'refs/tags/v')

0 commit comments

Comments
 (0)