Skip to content

CI: PR #564 by @lizthegrey - feat(lifecycle): notify and offer restart on in-place package upgrade #751

CI: PR #564 by @lizthegrey - feat(lifecycle): notify and offer restart on in-place package upgrade

CI: PR #564 by @lizthegrey - feat(lifecycle): notify and offer restart on in-place package upgrade #751

Workflow file for this run

name: CI
run-name: |
CI: ${{
github.event_name == 'pull_request' && format('PR #{0} by @{1} - {2}', github.event.pull_request.number, github.actor, github.event.pull_request.title) ||
github.event_name == 'push' && github.event.head_commit && format('Push by @{0} - {1}', github.actor, github.event.head_commit.message) ||
format('{0} triggered by @{1}', github.event_name, github.actor)
}}
on:
push:
branches:
- main
paths:
- "build.sh"
- "scripts/**"
- ".github/workflows/**"
tags:
- "v*"
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
jobs:
test-flags:
name: Test Flags Parsing
uses: ./.github/workflows/test-flags.yml
build-amd64:
name: Build Packages (amd64 - ${{ matrix.artifact_suffix }})
needs: test-flags
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
flags: "--build deb"
artifact_suffix: "deb"
- arch: amd64
flags: "--build rpm"
artifact_suffix: "rpm"
- arch: amd64
flags: "--build appimage --clean no"
artifact_suffix: "appimage"
uses: ./.github/workflows/build-amd64.yml
with:
build_flags: ${{ matrix.flags }}
artifact_suffix: ${{ matrix.artifact_suffix }}
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
test-artifacts:
name: Test Build Artifacts
needs: [build-amd64]
uses: ./.github/workflows/test-artifacts.yml
build-arm64:
name: Build Packages (arm64 - ${{ matrix.artifact_suffix }})
needs: test-flags
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
flags: "--build deb --clean no"
artifact_suffix: "deb"
- arch: arm64
flags: "--build rpm"
artifact_suffix: "rpm"
- arch: arm64
flags: "--build appimage"
artifact_suffix: "appimage"
uses: ./.github/workflows/build-arm64.yml
with:
build_flags: ${{ matrix.flags }}
artifact_suffix: ${{ matrix.artifact_suffix }}
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
release:
name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [test-flags, build-amd64, build-arm64, test-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download AMD64 deb artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-deb
path: artifacts/
- name: Download AMD64 rpm artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-rpm
path: artifacts/
- name: Download AMD64 AppImage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-appimage
path: artifacts/
- name: Download ARM64 deb artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-arm64-deb
path: artifacts/
- name: Download ARM64 rpm artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-arm64-rpm
path: artifacts/
- name: Download ARM64 AppImage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-arm64-appimage
path: artifacts/
# --- Release notes generation (inline, with fallback) ---
- name: Checkout claude-desktop-versions
id: checkout_versions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
continue-on-error: true
with:
repository: aaddrick/claude-desktop-versions
path: versions
- name: Set up Python 3.12
if: steps.checkout_versions.outcome == 'success'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
continue-on-error: true
with:
python-version: "3.12"
- name: Set up Node.js 20
if: steps.checkout_versions.outcome == 'success'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
continue-on-error: true
with:
node-version: "20"
- name: Install difftastic
if: steps.checkout_versions.outcome == 'success'
continue-on-error: true
run: |
curl -fsSL https://github.com/Wilfred/difftastic/releases/latest/download/difft-x86_64-unknown-linux-gnu.tar.gz \
| sudo tar xz -C /usr/local/bin
- name: Install Node.js tools
if: steps.checkout_versions.outcome == 'success'
continue-on-error: true
run: npm install -g @anthropic-ai/claude-code @electron/asar prettier
- name: Checkout repo for git history
id: checkout_repo
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
continue-on-error: true
with:
fetch-depth: 0
path: repo
- name: Find previous release tags
id: prev
if: steps.checkout_repo.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
current="${GITHUB_REF_NAME}"
current_cv="${current#*+claude}"
upstream_tag=""
any_tag=""
while IFS= read -r tag; do
# Skip the current tag
[[ "$tag" == "$current" ]] && continue
# Track the first previous tag (any version)
if [[ -z "$any_tag" ]]; then
any_tag="$tag"
fi
# Track the first tag with a different Claude version
tag_cv="${tag#*+claude}"
if [[ -z "$upstream_tag" && "$tag_cv" != "$current_cv" ]]; then
upstream_tag="$tag"
fi
# Stop once we have both
[[ -n "$any_tag" && -n "$upstream_tag" ]] && break
done < <(gh release list --limit 50 -R "$GITHUB_REPOSITORY" --json tagName -q '.[].tagName')
if [[ -n "$any_tag" ]]; then
any_cv="${any_tag#*+claude}"
if [[ "$any_cv" != "$current_cv" && -n "$upstream_tag" ]]; then
echo "type=upstream" >> "$GITHUB_OUTPUT"
echo "tag=$upstream_tag" >> "$GITHUB_OUTPUT"
echo "Upstream change: $upstream_tag -> $current"
else
echo "type=wrapper" >> "$GITHUB_OUTPUT"
echo "tag=$any_tag" >> "$GITHUB_OUTPUT"
echo "Wrapper-only update: $any_tag -> $current"
fi
else
echo "type=none" >> "$GITHUB_OUTPUT"
echo "::warning::No previous release found"
fi
- name: Run compare-releases (upstream change)
if: false # disabled — release notes are managed manually
# was: steps.prev.outcome == 'success' && steps.prev.outputs.type == 'upstream'
timeout-minutes: 180
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
appimage=$(find artifacts/ -name '*amd64*.AppImage' ! -name '*.zsync' | head -1)
python versions/scripts/compare-releases.py \
--old "${{ steps.prev.outputs.tag }}" \
--new "${GITHUB_REF_NAME}" \
--new-appimage "$appimage" \
--model sonnet \
--voice-profile-url "https://raw.githubusercontent.com/aaddrick/written-voice-replication/master/.claude/agents/aaddrick-voice.md" \
--workdir compare-work
- name: Append wrapper commits to upstream notes
if: steps.prev.outcome == 'success' && steps.prev.outputs.type == 'upstream'
continue-on-error: true
working-directory: repo
run: |
prev_tag="${{ steps.prev.outputs.tag }}"
current="${GITHUB_REF_NAME}"
commits=$(git log "${prev_tag}..${current}" --pretty=format:"- %s (%h)" --no-merges)
if [[ -n "$commits" && -f ../compare-work/summary.md ]]; then
{
echo ""
echo "---"
echo ""
echo "## Wrapper/Packaging Changes"
echo ""
echo "The following commits were made to the build wrapper and packaging between ${prev_tag} and ${current}:"
echo ""
echo "$commits"
} >> ../compare-work/summary.md
fi
- name: Generate commit-based notes (wrapper update)
if: steps.prev.outcome == 'success' && steps.prev.outputs.type == 'wrapper'
continue-on-error: true
working-directory: repo
run: |
prev_tag="${{ steps.prev.outputs.tag }}"
current="${GITHUB_REF_NAME}"
mkdir -p ../compare-work
{
echo "# Wrapper Update: ${current}"
echo ""
echo "This release updates the wrapper/packaging only — the upstream Claude Desktop version is unchanged."
echo ""
echo "## Changes since ${prev_tag}"
echo ""
git log "${prev_tag}..${current}" --pretty=format:"- %s (%h)" --no-merges
echo ""
echo ""
echo "---"
echo ""
echo "## Installation"
echo ""
echo "### APT (Debian/Ubuntu)"
echo ""
echo '```bash'
echo "# First time? Add the repo:"
echo "curl -fsSL https://pkg.claude-desktop-debian.dev/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/claude-desktop.gpg"
echo 'echo "deb [signed-by=/usr/share/keyrings/claude-desktop.gpg arch=amd64,arm64] https://pkg.claude-desktop-debian.dev stable main" | sudo tee /etc/apt/sources.list.d/claude-desktop.list'
echo ""
echo "# Install or update:"
echo "sudo apt update && sudo apt install claude-desktop"
echo '```'
echo ""
echo "### DNF (Fedora/RHEL)"
echo ""
echo '```bash'
echo "# First time? Add the repo:"
echo "sudo curl -fsSL https://pkg.claude-desktop-debian.dev/rpm/claude-desktop.repo -o /etc/yum.repos.d/claude-desktop.repo"
echo ""
echo "# Install or update:"
echo "sudo dnf install claude-desktop"
echo '```'
echo ""
echo "### AUR (Arch Linux)"
echo ""
echo '```bash'
echo "yay -S claude-desktop-appimage"
echo '```'
echo ""
echo "### Manual Download"
echo ""
echo "Download \`.deb\`, \`.rpm\`, or \`.AppImage\` from the assets below."
} > ../compare-work/summary.md
- name: Generate fallback release notes
if: ${{ always() }}
run: |
# Only generate fallback if AI-generated notes don't exist
if [[ -f compare-work/summary.md ]]; then
echo "AI-generated release notes found, skipping fallback"
exit 0
fi
# Extract Claude version from tag
tag="${GITHUB_REF_NAME}"
claude_version="${tag#*+claude}"
mkdir -p compare-work
{
echo "## Claude Desktop Update"
echo ""
echo "This release updates the packaged Claude Desktop version to **${claude_version}**."
echo ""
echo "### What's Changed"
echo "- Updated Claude Desktop to version ${claude_version}"
echo ""
echo "---"
echo ""
echo "## Installation"
echo ""
echo "### APT (Debian/Ubuntu)"
echo ""
echo '```bash'
echo "# First time? Add the repo:"
echo "curl -fsSL https://pkg.claude-desktop-debian.dev/KEY.gpg | sudo gpg --dearmor -o /usr/share/keyrings/claude-desktop.gpg"
echo 'echo "deb [signed-by=/usr/share/keyrings/claude-desktop.gpg arch=amd64,arm64] https://pkg.claude-desktop-debian.dev stable main" | sudo tee /etc/apt/sources.list.d/claude-desktop.list'
echo ""
echo "# Install or update:"
echo "sudo apt update && sudo apt install claude-desktop"
echo '```'
echo ""
echo "### DNF (Fedora/RHEL)"
echo ""
echo '```bash'
echo "# First time? Add the repo:"
echo "sudo curl -fsSL https://pkg.claude-desktop-debian.dev/rpm/claude-desktop.repo -o /etc/yum.repos.d/claude-desktop.repo"
echo ""
echo "# Install or update:"
echo "sudo dnf install claude-desktop"
echo '```'
echo ""
echo "### AUR (Arch Linux)"
echo ""
echo '```bash'
echo "yay -S claude-desktop-appimage"
echo '```'
echo ""
echo "### Manual Download"
echo ""
echo "Download \`.deb\`, \`.rpm\`, or \`.AppImage\` from the assets below."
} > compare-work/summary.md
- name: Create GitHub Release
if: ${{ always() }}
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: artifacts/**/*
body_path: compare-work/summary.md
- name: Generate and upload reference source
env:
GH_TOKEN: ${{ github.token }}
run: |
appimage=$(find artifacts/ -name '*amd64*.AppImage' ! -name '*.zsync' | head -1)
if [[ -z "$appimage" ]]; then
echo "::warning::No AppImage found, skipping reference-source"
exit 0
fi
chmod +x "$appimage"
"$appimage" --appimage-extract >/dev/null 2>&1
asar_path=$(find squashfs-root -name 'app.asar' -path '*/resources/*' | head -1)
if [[ -z "$asar_path" ]]; then
echo "::warning::app.asar not found in AppImage"
exit 0
fi
mkdir -p reference-source
asar extract "$asar_path" reference-source/app-extracted
npx prettier --write "reference-source/app-extracted/.vite/build/*.js" 2>/dev/null || true
tar -czf reference-source.tar.gz -C reference-source app-extracted
gh release upload "${{ github.ref_name }}" reference-source.tar.gz \
--repo "$GITHUB_REPOSITORY" --clobber
update-apt-repo:
name: Update APT Repository
if: startsWith(github.ref, 'refs/tags/v')
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: write
env:
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
steps:
- name: Checkout gh-pages branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: gh-pages
path: apt-repo
- name: Download AMD64 deb artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-deb
path: incoming/
- name: Download ARM64 deb artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-arm64-deb
path: incoming/
- name: Install reprepro
run: sudo apt-get update && sudo apt-get install -y reprepro
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
with:
gpg_private_key: ${{ secrets.APT_GPG_PRIVATE_KEY }}
- name: Publish KEY.gpg with all public keys from keyring
# Fix #501: APT InRelease and DNF repomd.xml are signed with
# different keys from the same keyring. Export every public key
# so strict clients (e.g. rockylinux:9) can verify both.
working-directory: apt-repo
run: |
gpg --armor --export > KEY.gpg
echo "Keys published in KEY.gpg:"
gpg --show-keys < KEY.gpg
- name: Add packages to repository
working-directory: apt-repo
run: |
# Remove existing versions to allow re-uploads and wrapper version bumps
# (reprepro rejects new files with same name but different checksums)
for deb in ../incoming/*.deb; do
pkg_name=$(dpkg-deb -f "$deb" Package)
if reprepro list stable "$pkg_name" 2>/dev/null | grep -q .; then
echo "Removing existing $pkg_name from repository..."
reprepro remove stable "$pkg_name"
fi
done
# Add each .deb file to the repository
# --section and --priority provide defaults for older packages missing these fields
for deb in ../incoming/*.deb; do
echo "Adding $deb to repository..."
reprepro --section utils --priority optional includedeb stable "$deb"
done
- name: Strip binaries from pool (gated on Worker liveness)
working-directory: apt-repo
run: |
# The Worker on WORKER_DOMAIN serves /pool/.../*.deb requests by
# 302-redirecting to GitHub Release assets. When it's live we strip
# binaries from the gh-pages tree (the metadata's Filename: field
# still references pool paths; the Worker intercepts).
# When the Worker isn't live (pre-Phase-4a, outage, misconfiguration)
# the strip is skipped to avoid serving 404s for binary fetches.
probe_url="https://${WORKER_DOMAIN}/dists/stable/InRelease"
if curl -fsI --max-time 10 "$probe_url" >/dev/null; then
echo "Worker live at ${WORKER_DOMAIN}; stripping binaries from pool"
find pool -type f -name '*.deb' -delete
else
echo "Worker not responding at ${WORKER_DOMAIN}; preserving .debs in pool"
echo "(expected before Phase 4a; after that, an error worth investigating)"
fi
- name: Commit and push changes
working-directory: apt-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --staged --quiet || git commit -m "Update APT repository for ${{ github.ref_name }}"
# Retry loop to handle concurrent pushes to gh-pages
for i in 1 2 3 4 5; do
git pull --rebase && git push && break
if [[ $i -eq 5 ]]; then
echo "::error::Failed to push APT repo after 5 attempts"
exit 1
fi
wait_time=$((2 ** i))
echo "Push failed, retrying in ${wait_time}s... (attempt $i/5)"
sleep "$wait_time"
done
- name: Smoke test published deb (ordered chain + size)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! curl -fsI --max-time 10 \
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
echo "Worker not live; skipping smoke test (expected before Phase 4a)"
exit 0
fi
# Parse versions from tag (e.g., v2.0.2+claude1.3883.0)
repoVer="${TAG#v}"; repoVer="${repoVer%+claude*}"
claudeVer="${TAG#*+claude}"
deb_name="claude-desktop_${claudeVer}-${repoVer}_amd64.deb"
# Intentionally starts at the github.io URL: the smoke test
# walks the full Pages-301 → Worker-302 → Releases chain to
# confirm the legacy redirect path still works for clients
# that follow HTTPS→HTTP downgrades (DNF, curl without -L).
deb_url="https://aaddrick.github.io/claude-desktop-debian/pool/main/c/claude-desktop/${deb_name}"
# Wait for propagation
deadline=$((SECONDS + 300))
until curl -fsI --max-time 10 "$deb_url" -o /dev/null; do
[[ $SECONDS -gt $deadline ]] \
&& { echo "::error::Reachability timeout for ${deb_url}"; exit 1; }
sleep 10
done
# Walk redirect chain hop-by-hop
# Hop 0 is Pages' auto-301 from github.io to pkg.<domain>.
# Pages emits http:// in the Location because https_enforced
# can't be set (DNS points at Cloudflare, not Pages, so Pages
# can't provision its own cert). Cloudflare/Worker answers
# both schemes, so http vs https is cosmetic here.
expected_hops=(
"https?://${WORKER_DOMAIN}/"
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/v${repoVer}\\+claude${claudeVer}/"
"https://(objects|release-assets)\\.githubusercontent\\.com/"
)
url="$deb_url"
for i in "${!expected_hops[@]}"; do
hop_status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
redirect_url=$(curl -s -o /dev/null -w '%{redirect_url}' "$url")
echo "Hop ${i}: ${hop_status} ${url} -> ${redirect_url}"
[[ "$hop_status" =~ ^30[12]$ ]] \
|| { echo "::error::Hop ${i} expected 301/302, got ${hop_status}"; exit 1; }
[[ "$redirect_url" =~ ^${expected_hops[$i]} ]] \
|| { echo "::error::Hop ${i} mismatch: expected ${expected_hops[$i]}, got ${redirect_url}"; exit 1; }
url="$redirect_url"
done
# Fetch and validate
curl -fsSL -o /tmp/smoke.deb "$deb_url"
file /tmp/smoke.deb | grep -q 'Debian binary package' \
|| { echo "::error::Not a valid Debian package"; exit 1; }
# Size match against the Releases asset
asset_size=$(gh release view "$TAG" \
--repo aaddrick/claude-desktop-debian \
--json assets \
--jq ".assets[] | select(.name == \"${deb_name}\") | .size")
local_size=$(stat -c %s /tmp/smoke.deb)
[[ "$asset_size" == "$local_size" ]] \
|| { echo "::error::Size mismatch: ${local_size} vs ${asset_size}"; exit 1; }
echo "APT smoke test passed: chain validated, file matches Releases asset"
update-dnf-repo:
name: Update DNF Repository
if: startsWith(github.ref, 'refs/tags/v')
needs: [release, update-apt-repo]
runs-on: ubuntu-latest
permissions:
contents: write
env:
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
steps:
- name: Checkout gh-pages branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: gh-pages
path: dnf-repo
- name: Download AMD64 rpm artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-rpm
path: incoming/
- name: Download ARM64 rpm artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-arm64-rpm
path: incoming/
- name: Install createrepo_c and rpm-sign
run: sudo apt-get update && sudo apt-get install -y createrepo-c rpm
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
with:
gpg_private_key: ${{ secrets.APT_GPG_PRIVATE_KEY }}
- name: Configure RPM signing
run: |
# Configure RPM macros for signing
cat > ~/.rpmmacros << EOF
%_signature gpg
%_gpg_name ${{ steps.import_gpg.outputs.keyid }}
%__gpg /usr/bin/gpg
%__gpg_sign_cmd %{__gpg} gpg --batch --no-verbose --no-armor --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} %{__plaintext_filename}
EOF
- name: Update DNF repository
working-directory: dnf-repo
run: |
# Create directory structure
mkdir -p rpm/x86_64 rpm/aarch64
# Remove old RPMs to prevent accumulation across releases
rm -f rpm/x86_64/*.rpm rpm/aarch64/*.rpm
# Copy RPMs to appropriate architecture directories
for rpm_file in ../incoming/*.rpm; do
filename=$(basename "$rpm_file")
if [[ "$filename" == *"x86_64"* ]]; then
cp "$rpm_file" rpm/x86_64/
echo "Added $filename to x86_64"
elif [[ "$filename" == *"aarch64"* ]]; then
cp "$rpm_file" rpm/aarch64/
echo "Added $filename to aarch64"
fi
done
# Sign RPM packages and generate repository metadata for each architecture
for arch in x86_64 aarch64; do
if ls "rpm/$arch/"*.rpm 1> /dev/null 2>&1; then
# Sign each RPM package
echo "Signing RPM packages for $arch..."
for rpm_file in "rpm/$arch/"*.rpm; do
echo "Signing $rpm_file..."
rpmsign --addsign "$rpm_file"
done
echo "Generating repodata for $arch..."
createrepo_c --update "rpm/$arch/"
# Sign the repository metadata (--yes to overwrite existing signature)
echo "Signing repodata for $arch..."
gpg --batch --yes --default-key "${{ steps.import_gpg.outputs.keyid }}" --detach-sign --armor "rpm/$arch/repodata/repomd.xml"
fi
done
# Create .repo file for users (reuses existing KEY.gpg)
# shellcheck disable=SC2016 # $basearch is a DNF variable, not a shell variable
printf '%s\n' \
'[claude-desktop]' \
'name=Claude Desktop for Fedora/RHEL' \
'baseurl=https://pkg.claude-desktop-debian.dev/rpm/$basearch' \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
> rpm/claude-desktop.repo
- name: Re-upload signed RPMs to GitHub Release
# Fix #500: rpmsign --addsign mutates the RPM in place. The release
# job (needs: release) already uploaded the unsigned build artifact.
# Clobber it with the signed copy so the sha256 in repodata matches
# the binary the Worker redirects to.
env:
GH_TOKEN: ${{ github.token }}
working-directory: dnf-repo
run: |
for arch in x86_64 aarch64; do
if ls "rpm/$arch/"*.rpm 1> /dev/null 2>&1; then
gh release upload "${{ github.ref_name }}" \
"rpm/$arch/"*.rpm \
--repo aaddrick/claude-desktop-debian \
--clobber
fi
done
- name: Strip RPMs from pool (gated on Worker liveness)
working-directory: dnf-repo
run: |
# Mirror of the APT-side strip. Repodata (signed) stays; the .rpm
# binaries themselves are deleted because the Worker 302-redirects
# /rpm/<arch>/*.rpm requests to GitHub Release assets.
probe_url="https://${WORKER_DOMAIN}/dists/stable/InRelease"
if curl -fsI --max-time 10 "$probe_url" >/dev/null; then
echo "Worker live; stripping RPMs from pool (repodata + signatures retained)"
find rpm -type f -name '*.rpm' -delete
else
echo "Worker not responding; preserving .rpms in pool"
echo "(expected before Phase 4a; after that, an error worth investigating)"
fi
- name: Commit and push changes
working-directory: dnf-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --staged --quiet || git commit -m "Update DNF repository for ${{ github.ref_name }}"
# Retry loop to handle concurrent pushes to gh-pages
for i in 1 2 3 4 5; do
git pull --rebase && git push && break
if [[ $i -eq 5 ]]; then
echo "::error::Failed to push DNF repo after 5 attempts"
exit 1
fi
wait_time=$((2 ** i))
echo "Push failed, retrying in ${wait_time}s... (attempt $i/5)"
sleep "$wait_time"
done
- name: Smoke test published rpm (ordered chain + size)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! curl -fsI --max-time 10 \
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
echo "Worker not live; skipping smoke test (expected before Phase 4a)"
exit 0
fi
repoVer="${TAG#v}"; repoVer="${repoVer%+claude*}"
claudeVer="${TAG#*+claude}"
rpm_name="claude-desktop-${claudeVer}-${repoVer}-1.x86_64.rpm"
# Intentionally starts at the github.io URL — see APT smoke
# test comment above for why.
rpm_url="https://aaddrick.github.io/claude-desktop-debian/rpm/x86_64/${rpm_name}"
deadline=$((SECONDS + 300))
until curl -fsI --max-time 10 "$rpm_url" -o /dev/null; do
[[ $SECONDS -gt $deadline ]] \
&& { echo "::error::Reachability timeout for ${rpm_url}"; exit 1; }
sleep 10
done
# Hop 0 is Pages' auto-301 from github.io to pkg.<domain>.
# Pages emits http:// in the Location because https_enforced
# can't be set (DNS points at Cloudflare, not Pages, so Pages
# can't provision its own cert). Cloudflare/Worker answers
# both schemes, so http vs https is cosmetic here.
expected_hops=(
"https?://${WORKER_DOMAIN}/"
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/v${repoVer}\\+claude${claudeVer}/"
"https://(objects|release-assets)\\.githubusercontent\\.com/"
)
url="$rpm_url"
for i in "${!expected_hops[@]}"; do
hop_status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
redirect_url=$(curl -s -o /dev/null -w '%{redirect_url}' "$url")
echo "Hop ${i}: ${hop_status} ${url} -> ${redirect_url}"
[[ "$hop_status" =~ ^30[12]$ ]] \
|| { echo "::error::Hop ${i} expected 301/302, got ${hop_status}"; exit 1; }
[[ "$redirect_url" =~ ^${expected_hops[$i]} ]] \
|| { echo "::error::Hop ${i} mismatch: expected ${expected_hops[$i]}, got ${redirect_url}"; exit 1; }
url="$redirect_url"
done
curl -fsSL -o /tmp/smoke.rpm "$rpm_url"
rpm -qpi /tmp/smoke.rpm >/dev/null \
|| { echo "::error::Not a valid RPM"; exit 1; }
asset_size=$(gh release view "$TAG" \
--repo aaddrick/claude-desktop-debian \
--json assets \
--jq ".assets[] | select(.name == \"${rpm_name}\") | .size")
local_size=$(stat -c %s /tmp/smoke.rpm)
[[ "$asset_size" == "$local_size" ]] \
|| { echo "::error::Size mismatch: ${local_size} vs ${asset_size}"; exit 1; }
echo "DNF smoke test passed: chain validated, file matches Releases asset"
update-aur-repo:
name: Update AUR Package
if: startsWith(github.ref, 'refs/tags/v')
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Download AMD64 AppImage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-amd64-appimage
path: artifacts/
- name: Extract version components from tag
id: version
run: |
tag="${GITHUB_REF_NAME}"
# Tag format: v1.3.8+claude1.1.799
# pkgver for AUR: 1.3.8+claude1.1.799
pkgver="${tag#v}"
# Wrapper version: 1.3.8 (before +claude)
wrapper_ver="${pkgver%%+claude*}"
# Claude version: 1.1.799 (after +claude)
claude_ver="${pkgver#*+claude}"
# AppImage filename: claude-desktop-{claude_ver}-{wrapper_ver}-amd64.AppImage
appimage_name="claude-desktop-${claude_ver}-${wrapper_ver}-amd64.AppImage"
echo "pkgver=$pkgver" >> "$GITHUB_OUTPUT"
echo "appimage_name=$appimage_name" >> "$GITHUB_OUTPUT"
echo "Tag: $tag"
echo "pkgver: $pkgver"
echo "AppImage name: $appimage_name"
- name: Compute AppImage checksum
id: checksum
env:
APPIMAGE_NAME: ${{ steps.version.outputs.appimage_name }}
run: |
appimage="artifacts/${APPIMAGE_NAME}"
if [[ ! -f "$appimage" ]]; then
echo "::error::Expected AppImage not found: ${APPIMAGE_NAME}"
echo "Available artifacts:"
ls -la artifacts/
exit 1
fi
sha256=$(sha256sum "$appimage" | awk '{print $1}')
echo "sha256=$sha256" >> "$GITHUB_OUTPUT"
echo "AppImage: ${APPIMAGE_NAME}"
echo "SHA256: $sha256"
- name: Configure SSH for AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
if [[ -z "$AUR_SSH_KEY" ]]; then
echo "::error::AUR_SSH_PRIVATE_KEY secret is not configured"
exit 1
fi
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan -t ed25519,rsa aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
cat > ~/.ssh/config <<-'EOF'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
EOF
chmod 600 ~/.ssh/config
- name: Clone AUR repository
run: |
git clone ssh://[email protected]/claude-desktop-appimage.git aur-repo
- name: Generate PKGBUILD from template
working-directory: aur-repo
env:
PKGVER: ${{ steps.version.outputs.pkgver }}
APPIMAGE_NAME: ${{ steps.version.outputs.appimage_name }}
SHA256: ${{ steps.checksum.outputs.sha256 }}
run: |
if [[ ! -f PKGBUILD.template ]]; then
echo "::error::PKGBUILD.template not found in AUR repository"
exit 1
fi
sed \
-e "s/%%PKGVER%%/${PKGVER}/" \
-e "s/%%APPIMAGE_NAME%%/${APPIMAGE_NAME}/" \
-e "s/%%SHA256_APPIMAGE%%/${SHA256}/" \
PKGBUILD.template > PKGBUILD
- name: Generate .SRCINFO
working-directory: aur-repo
run: |
docker run --rm -v "$PWD":/pkg -w /pkg archlinux:base bash -c "
useradd -m builder
chown builder:builder /pkg
find /pkg -maxdepth 1 -not -name .git -not -path /pkg -exec chown -R builder:builder {} +
su builder -c 'makepkg --printsrcinfo > .SRCINFO'
"
# Restore ownership after docker (container uid may differ from runner)
sudo chown -R "$(id -u):$(id -g)" .
- name: Commit and push to AUR
working-directory: aur-repo
env:
PKGVER: ${{ steps.version.outputs.pkgver }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update to v${PKGVER}"
git push