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: name: Build Packages (${{ matrix.arch }} - ${{ 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" - 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.yml with: arch: ${{ matrix.arch }} 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 (amd64) needs: [build] uses: ./.github/workflows/test-artifacts.yml with: arch: amd64 test-artifacts-arm64: name: Test Build Artifacts (arm64) needs: [build] uses: ./.github/workflows/test-artifacts.yml with: arch: arm64 release: name: Create Release if: startsWith(github.ref, 'refs/tags/v') needs: [test-flags, build, test-artifacts, test-artifacts-arm64] 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-unofficial.gpg" echo 'echo "deb [signed-by=/usr/share/keyrings/claude-desktop-unofficial.gpg arch=amd64,arm64] https://pkg.claude-desktop-debian.dev stable main" | sudo tee /etc/apt/sources.list.d/claude-desktop-unofficial.list' echo "" echo "# Install or update:" echo "sudo apt update && sudo apt install claude-desktop-unofficial" 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-unofficial.repo -o /etc/yum.repos.d/claude-desktop-unofficial.repo" echo "" echo "# Install or update:" echo "sudo dnf install claude-desktop-unofficial" 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-unofficial.gpg" echo 'echo "deb [signed-by=/usr/share/keyrings/claude-desktop-unofficial.gpg arch=amd64,arm64] https://pkg.claude-desktop-debian.dev stable main" | sudo tee /etc/apt/sources.list.d/claude-desktop-unofficial.list' echo "" echo "# Install or update:" echo "sudo apt update && sudo apt install claude-desktop-unofficial" 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-unofficial.repo -o /etc/yum.repos.d/claude-desktop-unofficial.repo" echo "" echo "# Install or update:" echo "sudo dnf install claude-desktop-unofficial" 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 # RC tags (e.g. v3.0.0-rc1+claude...) publish as a GitHub # prerelease — the opt-in beta channel — while final tags # publish as a full release. prerelease: ${{ contains(github.ref_name, '-rc') }} - 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 mirror-official-deb: name: Mirror Official .deb to Release # Runs for RC tags too — the mirror is version-keyed insurance so the # exact upstream binary a release was built from stays retrievable # from our Releases even if it rotates out of Anthropic's pool. if: startsWith(github.ref, 'refs/tags/v') needs: [release] runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Mirror pinned official .deb per architecture env: GH_TOKEN: ${{ github.token }} run: | source scripts/_common.sh source scripts/setup/official-deb.sh for arch in amd64 arm64; do official_deb_pin "$arch" || { echo "::error::Failed to pin official .deb for $arch" exit 1 } echo "Mirroring $official_deb_filename ($arch)..." if ! wget -q -O "$official_deb_filename" "$official_deb_url"; then echo "::error::Failed to download $official_deb_url" exit 1 fi if ! verify_sha256 "$official_deb_filename" \ "$official_deb_sha256" "official $arch .deb"; then exit 1 fi # Upload under the original pool filename # (claude-desktop__.deb). It cannot collide with our # own assets, which carry the _-_ wrapper suffix. gh release upload "$GITHUB_REF_NAME" "$official_deb_filename" \ --clobber done update-apt-repo: name: Update APT Repository # RC tags never touch the stable APT repo. if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-rc') 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: | # incoming/ carries our renamed claude-desktop-unofficial .debs # plus the transitional dummy claude-desktop__all.deb # (Depends: claude-desktop-unofficial) that auto-migrates legacy # `apt install claude-desktop` users. reprepro derives pool paths # from package names, so they land in # pool/main/c/claude-desktop-unofficial/ and # pool/main/c/claude-desktop/ respectively. # 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). The find # covers both pool dirs: our claude-desktop-unofficial debs and # the transitional claude-desktop_*_all.deb under # pool/main/c/claude-desktop/. # 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-unofficial_${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-unofficial/${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.. # 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 # RC tags never touch the stable DNF repo. if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-rc') 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 repodata. Trailing '!' on keyid forces gpg to use # the primary key; without it gpg picks the most recent # signing subkey, and rpm 4.20+ / zypper reject repomd.xml # signed by anything other than the primary key. # Regression of #213 — PR #217 added --default-key but # dropped the '!'. Do not strip it. --yes overwrites .asc. 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-unofficial]' \ 'name=Claude Desktop (unofficial packaging) 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' \ 'metadata_expire=1h' \ > rpm/claude-desktop-unofficial.repo # Legacy path: rpm/claude-desktop.repo predates the # claude-desktop-unofficial rename and is baked into old install # instructions and bookmarks. Keep publishing it with the same # baseurl/key so those URLs keep working. It retains the original # [claude-desktop] section id — existing installs already carry # that id, and reusing [claude-desktop-unofficial] here would # create a duplicate repo id for anyone holding both files. # 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' \ 'metadata_expire=1h' \ > 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//*.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-unofficial-${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.. # 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 # RC tags never touch the AUR package. if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-rc') 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-unofficial-{claude_ver}-{wrapper_ver}-amd64.AppImage # (the AUR *package* stays claude-desktop-appimage; only the # release asset it downloads carries the -unofficial name) appimage_name="claude-desktop-unofficial-${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://aur@aur.archlinux.org/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