3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
990 lines
39 KiB
YAML
990 lines
39 KiB
YAML
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_<ver>_<arch>.deb). It cannot collide with our
|
|
# own assets, which carry the _<ver>-<repo>_ 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_<ver>_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.<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
|
|
# 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/<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-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.<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
|
|
# 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
|
|
|