CI: PR #564 by @lizthegrey - feat(lifecycle): notify and offer restart on in-place package upgrade #751
Workflow file for this run
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
| 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 | |