chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:21 +08:00
commit 3e076d4dd9
341 changed files with 80308 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
name: APT/DNF Repo Heartbeat
# Walks the published .deb and .rpm URLs through the full
# Pages 301 → Worker 302 → Releases 302 → CDN 200 chain daily,
# asserts ordered hops, asserts size match against the Releases
# asset, and opens a tracking issue (with a format-specific label)
# on failure. Auto-closes the issue when the format recovers.
#
# Pre-Phase-4a: the gate step skips gracefully when the production
# Worker isn't live yet. Once Phase 4a is done, the gate passes
# and the full chain is exercised every day.
on:
schedule:
- cron: '0 12 * * *' # daily noon UTC
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
ping:
strategy:
fail-fast: false
matrix:
# deb-transitional probes the fixed-version dummy package
# (Package: claude-desktop → Depends: claude-desktop-unofficial)
# that migrates legacy apt installs; its Worker redirect goes
# via releases/latest and gets one extra hop.
format: [deb, rpm, deb-transitional]
runs-on: ubuntu-latest
env:
WORKER_DOMAIN: pkg.claude-desktop-debian.dev
GH_TOKEN: ${{ github.token }}
steps:
- name: Skip if Worker not live yet
id: gate
run: |
if curl -fsI --max-time 10 \
"https://${WORKER_DOMAIN}/dists/stable/InRelease" >/dev/null; then
echo "live=true" >> "$GITHUB_OUTPUT"
echo "Worker live; running heartbeat."
else
echo "live=false" >> "$GITHUB_OUTPUT"
echo "Worker not live; heartbeat skipping (expected before Phase 4a)."
fi
- name: Resolve latest release for ${{ matrix.format }}
if: steps.gate.outputs.live == 'true'
id: latest
run: |
tag=$(gh release list --limit 1 --json tagName \
--jq '.[0].tagName' \
--repo aaddrick/claude-desktop-debian)
repoVer="${tag#v}"; repoVer="${repoVer%+claude*}"
claudeVer="${tag#*+claude}"
if [[ "${{ matrix.format }}" == "deb" ]]; then
asset="claude-desktop-unofficial_${claudeVer}-${repoVer}_amd64.deb"
url="https://aaddrick.github.io/claude-desktop-debian/pool/main/c/claude-desktop-unofficial/${asset}"
elif [[ "${{ matrix.format }}" == "deb-transitional" ]]; then
# Fixed-version dummy deb; filename is release-independent
asset="claude-desktop_1.16000.0-1_all.deb"
url="https://aaddrick.github.io/claude-desktop-debian/pool/main/c/claude-desktop/${asset}"
else
asset="claude-desktop-unofficial-${claudeVer}-${repoVer}-1.x86_64.rpm"
url="https://aaddrick.github.io/claude-desktop-debian/rpm/x86_64/${asset}"
fi
{
echo "tag=${tag}"
echo "asset=${asset}"
echo "url=${url}"
} >> "$GITHUB_OUTPUT"
- name: Validate ordered chain + fetch + size match
if: steps.gate.outputs.live == 'true'
env:
ASSET: ${{ steps.latest.outputs.asset }}
URL: ${{ steps.latest.outputs.url }}
TAG: ${{ steps.latest.outputs.tag }}
FORMAT: ${{ matrix.format }}
run: |
set -euo pipefail
# Wait for propagation; fail after 5 min instead of cargo-cult sleep
deadline=$((SECONDS + 300))
until curl -fsI --max-time 10 "$URL" -o /dev/null; do
if [[ $SECONDS -gt $deadline ]]; then
echo "::error::Reachability timeout for ${URL}"
exit 1
fi
sleep 10
done
# Walk redirect chain hop-by-hop, asserting each hop's pattern
# in order. Hop 0 may be http:// (see ci.yml smoke-test comment
# for the Pages https_enforced=false background).
expected_hops=(
"https?://${WORKER_DOMAIN}/"
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/"
"https://(objects|release-assets)\\.githubusercontent\\.com/"
)
if [[ "$FORMAT" == "deb-transitional" ]]; then
# The transitional deb's fixed version encodes no release
# tag, so the Worker sends it to releases/latest/download,
# which GitHub resolves with one extra 302.
expected_hops=(
"https?://${WORKER_DOMAIN}/"
"https://github\\.com/aaddrick/claude-desktop-debian/releases/latest/download/"
"https://github\\.com/aaddrick/claude-desktop-debian/releases/download/"
"https://(objects|release-assets)\\.githubusercontent\\.com/"
)
fi
url="$URL"
release_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}"
if [[ ! "$hop_status" =~ ^30[12]$ ]]; then
echo "::error::Hop ${i}: expected 301/302, got ${hop_status}"
exit 1
fi
if [[ ! "$redirect_url" =~ ^${expected_hops[$i]} ]]; then
echo "::error::Hop ${i} mismatch:"
echo "::error:: expected: ${expected_hops[$i]}"
echo "::error:: got: ${redirect_url}"
exit 1
fi
# Remember the tag-bearing releases/download hop for the
# size check (needed for the transitional deb, whose chain
# may land on a different tag than the newest listed one).
if [[ "$redirect_url" == */releases/download/* ]]; then
release_url="$redirect_url"
fi
url="$redirect_url"
done
# Fetch the asset and validate its format
curl -fsSL -o "/tmp/${ASSET}" "$URL"
if [[ "$FORMAT" == deb* ]]; then
if ! file "/tmp/${ASSET}" | grep -q 'Debian binary package'; then
echo "::error::Fetched file is not a valid Debian package"
exit 1
fi
else
sudo apt-get update >/dev/null
sudo apt-get install -y rpm >/dev/null
if ! rpm -qpi "/tmp/${ASSET}" >/dev/null 2>&1; then
echo "::error::Fetched file is not a valid RPM"
exit 1
fi
fi
# Size match against the Releases asset. The transitional deb
# rides releases/latest, which skips prereleases and so may
# resolve to an older tag than `gh release list` returns —
# take the tag from the chain it actually walked instead.
release_tag="$TAG"
if [[ "$FORMAT" == "deb-transitional" ]]; then
release_tag="${release_url#*/releases/download/}"
release_tag="${release_tag%%/*}"
release_tag="${release_tag//%2B/+}" # GitHub may URL-encode '+'
fi
asset_size=$(gh release view "$release_tag" \
--repo aaddrick/claude-desktop-debian \
--json assets \
--jq ".assets[] | select(.name == \"${ASSET}\") | .size")
local_size=$(stat -c %s "/tmp/${ASSET}")
if [[ "$asset_size" != "$local_size" ]]; then
echo "::error::Size mismatch: local ${local_size} vs Releases ${asset_size}"
exit 1
fi
echo "Heartbeat passed: chain validated, file matches Releases asset."
- name: Open or update failure issue
if: failure() && steps.gate.outputs.live == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
FORMAT: ${{ matrix.format }}
with:
script: |
const fmt = process.env.FORMAT;
const label = `heartbeat-failure-${fmt}`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `Heartbeat failed for \`${fmt}\` at ${new Date().toISOString()}.\nRun: ${runUrl}`;
const { data: open } = await github.rest.issues.listForRepo({
...context.repo,
labels: label,
state: 'open',
});
if (open.length === 0) {
await github.rest.issues.create({
...context.repo,
title: `APT/DNF repo heartbeat failing (${fmt})`,
body,
labels: [label],
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: open[0].number,
body,
});
}
- name: Auto-close failure issue on recovery
if: success() && steps.gate.outputs.live == 'true'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
FORMAT: ${{ matrix.format }}
with:
script: |
const fmt = process.env.FORMAT;
const label = `heartbeat-failure-${fmt}`;
const { data: open } = await github.rest.issues.listForRepo({
...context.repo,
labels: label,
state: 'open',
});
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: `Heartbeat for \`${fmt}\` recovered at ${new Date().toISOString()}; auto-closing.`,
});
await github.rest.issues.update({
...context.repo,
issue_number: issue.number,
state: 'closed',
});
}
+77
View File
@@ -0,0 +1,77 @@
name: Build Package (Reusable)
# Single cross-building reusable workflow. Repackaging Anthropic's
# prebuilt official .deb is arch-independent (no compilation), so both
# amd64 and arm64 build on ubuntu-latest; build.sh fetches the official
# .deb for `--arch` and re-emits it in the requested format.
on:
workflow_call:
inputs:
arch:
description: "Target architecture (amd64 or arm64)"
required: true
type: string
build_flags:
description: 'Flags to pass to build.sh (e.g., "--build appimage --clean no")'
required: false
type: string
default: ""
artifact_suffix:
description: "Suffix for the artifact name (e.g., deb, appimage, rpm)"
required: true
type: string
release_tag:
description: "Optional release tag (e.g., v1.3.2+claude1.1.799) to derive wrapper version"
required: false
type: string
default: ""
jobs:
build:
runs-on: ubuntu-latest
container: ${{ inputs.artifact_suffix == 'rpm' && 'fedora:42' || '' }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install dependencies (Fedora)
if: inputs.artifact_suffix == 'rpm'
run: |
dnf install -y git findutils
- name: Install FUSE for AppImageTool (Ubuntu)
if: inputs.artifact_suffix != 'rpm'
run: |
sudo apt-get update
sudo apt-get install -y libfuse2
- name: Make build script executable
run: chmod +x ./build.sh
- name: Run build script
run: |
tag_flag=()
if [[ -n "${{ inputs.release_tag }}" ]]; then
tag_flag=(--release-tag "${{ inputs.release_tag }}")
fi
# build_flags is intentionally unquoted so its space-separated
# flags word-split into separate argv entries.
./build.sh ${{ inputs.build_flags }} --arch ${{ inputs.arch }} "${tag_flag[@]}"
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
# Our artifacts carry the claude-desktop-unofficial name. The
# amd64 deb leg additionally emits the transitional dummy
# claude-desktop_<ver>_all.deb (Depends: claude-desktop-unofficial)
# that migrates legacy apt installs — the second glob picks it up.
with:
name: package-${{ inputs.arch }}-${{ inputs.artifact_suffix }}
path: |
claude-desktop-unofficial_*.deb
claude-desktop_*_all.deb
claude-desktop-unofficial-*.rpm
claude-desktop-unofficial-*.AppImage
claude-desktop-unofficial-*.AppImage.zsync
if-no-files-found: error
+213
View File
@@ -0,0 +1,213 @@
name: Check Claude Desktop Version
on:
schedule:
- cron: "0 1 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: main-branch-auto-update
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Resolve newest official versions
id: resolve
run: |
source scripts/_common.sh
source scripts/setup/official-deb.sh
# amd64 is authoritative — a failure here fails the job.
if ! resolve_official_deb amd64; then
echo "::error::Failed to resolve amd64 official package"
exit 1
fi
{
echo "amd64_version=$resolved_official_version"
echo "amd64_filename=$resolved_official_filename"
echo "amd64_sha256=$resolved_official_sha256"
} >> "$GITHUB_OUTPUT"
# arm64 may lag the pool; a failure just leaves arm64_version
# empty, which the cross-arch gate treats as "not yet published".
if resolve_official_deb arm64; then
{
echo "arm64_version=$resolved_official_version"
echo "arm64_filename=$resolved_official_filename"
echo "arm64_sha256=$resolved_official_sha256"
} >> "$GITHUB_OUTPUT"
else
echo "::warning::Failed to resolve arm64 official package"
fi
- name: Check if update needed
id: check_update
run: |
AMD64_VERSION="${{ steps.resolve.outputs.amd64_version }}"
ARM64_VERSION="${{ steps.resolve.outputs.arm64_version }}"
CURRENT_PIN=$(grep -oP "^OFFICIAL_DEB_VERSION='\K[^']+" \
scripts/setup/official-deb.sh)
STORED_CLAUDE_VERSION="${{ vars.CLAUDE_DESKTOP_VERSION }}"
REPO_VERSION="${{ vars.REPO_VERSION }}"
echo "Resolved amd64 version: $AMD64_VERSION"
echo "Resolved arm64 version: $ARM64_VERSION"
echo "Current pin (OFFICIAL_DEB_VERSION): $CURRENT_PIN"
echo "Stored CLAUDE_DESKTOP_VERSION: $STORED_CLAUDE_VERSION"
echo "Repository version: $REPO_VERSION"
# Cross-arch agreement gate: the official pool occasionally
# publishes one arch ahead of the other. Only bump when both
# arches expose the same version, so a half-published release
# never pins mismatched binaries.
if [[ "$AMD64_VERSION" != "$ARM64_VERSION" ]]; then
echo "pool lag — arches disagree" \
"(amd64='$AMD64_VERSION' arm64='$ARM64_VERSION');" \
"skipping until both publish"
echo "update_needed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VER="$AMD64_VERSION"
UPDATE_NEEDED=false
if [[ "$VER" != "$CURRENT_PIN" ]]; then
echo "Version differs from pin ($CURRENT_PIN -> $VER)"
UPDATE_NEEDED=true
fi
if [[ "$VER" != "$STORED_CLAUDE_VERSION" ]]; then
echo "Version differs from CLAUDE_DESKTOP_VERSION"
UPDATE_NEEDED=true
fi
if [[ "$UPDATE_NEEDED" == "true" ]]; then
NEW_TAG="v${REPO_VERSION}+claude${VER}"
{
echo "update_needed=true"
echo "claude_version=$VER"
echo "new_tag=$NEW_TAG"
} >> "$GITHUB_OUTPUT"
echo "Update needed! New tag will be: $NEW_TAG"
else
echo "No updates needed"
echo "update_needed=false" >> "$GITHUB_OUTPUT"
fi
- name: Update official-deb.sh pins
if: steps.check_update.outputs.update_needed == 'true'
run: |
VER="${{ steps.check_update.outputs.claude_version }}"
AMD64_FILENAME="${{ steps.resolve.outputs.amd64_filename }}"
AMD64_SHA256="${{ steps.resolve.outputs.amd64_sha256 }}"
ARM64_FILENAME="${{ steps.resolve.outputs.arm64_filename }}"
ARM64_SHA256="${{ steps.resolve.outputs.arm64_sha256 }}"
f="scripts/setup/official-deb.sh"
# The resolver's Filename: field is already pool-relative, so it
# drops straight into the OFFICIAL_DEB_POOL_* pins. Each sed
# anchors on ^NAME= so the five pin lines rewrite unambiguously.
# '|' delimiter clears the '/' in the pool paths.
sed -i "s|^OFFICIAL_DEB_VERSION=.*|OFFICIAL_DEB_VERSION='$VER'|" "$f"
sed -i "s|^OFFICIAL_DEB_POOL_AMD64=.*|OFFICIAL_DEB_POOL_AMD64='$AMD64_FILENAME'|" "$f"
sed -i "s|^OFFICIAL_DEB_SHA256_AMD64=.*|OFFICIAL_DEB_SHA256_AMD64='$AMD64_SHA256'|" "$f"
sed -i "s|^OFFICIAL_DEB_POOL_ARM64=.*|OFFICIAL_DEB_POOL_ARM64='$ARM64_FILENAME'|" "$f"
sed -i "s|^OFFICIAL_DEB_SHA256_ARM64=.*|OFFICIAL_DEB_SHA256_ARM64='$ARM64_SHA256'|" "$f"
echo "Updated pins in $f:"
grep -E "^OFFICIAL_DEB_(VERSION|POOL|SHA256)" "$f"
- name: Update Nix SRI hashes
if: steps.check_update.outputs.update_needed == 'true'
run: |
NIX_FILE="nix/claude-desktop.nix"
# The Nix derivation is a `throw` stub until @typedrat lands the
# official-deb rework. Skip cleanly until it exposes a real
# `version = "..."` line to anchor on.
if ! grep -q 'version = ' "$NIX_FILE"; then
echo "nix derivation is still a stub; skipping SRI update"
exit 0
fi
VER="${{ steps.check_update.outputs.claude_version }}"
AMD64_SHA256="${{ steps.resolve.outputs.amd64_sha256 }}"
ARM64_SHA256="${{ steps.resolve.outputs.arm64_sha256 }}"
# The APT Packages index SHA-256 is authoritative — no download.
# Convert the hex digest to the SRI (base64) form Nix expects.
amd64_sri="sha256-$(echo "$AMD64_SHA256" | xxd -r -p | base64 -w0)"
arm64_sri="sha256-$(echo "$ARM64_SHA256" | xxd -r -p | base64 -w0)"
sed -i "s|version = \"[^\"]*\"|version = \"$VER\"|" "$NIX_FILE"
# Expected per-arch block shape once the derivation lands:
# x86_64-linux = { url = "..."; hash = "sha256-..."; };
# aarch64-linux = { url = "..."; hash = "sha256-..."; };
# Each arch block holds exactly one `hash = "..."` before its
# closing `};`, so the range sed rewrites only that arch's hash.
sed -i "/x86_64-linux/,/};/{s|hash = \"[^\"]*\"|hash = \"$amd64_sri\"|}" "$NIX_FILE"
sed -i "/aarch64-linux/,/};/{s|hash = \"[^\"]*\"|hash = \"$arm64_sri\"|}" "$NIX_FILE"
echo "Updated $NIX_FILE (version + per-arch SRI hashes)"
- name: Commit and push changes
if: steps.check_update.outputs.update_needed == 'true'
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
VER="${{ steps.check_update.outputs.claude_version }}"
NEW_TAG="${{ steps.check_update.outputs.new_tag }}"
# Check if we have a PAT
if [ -z "$GH_TOKEN" ]; then
echo "Error: GH_PAT secret is not configured"
exit 1
fi
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Check if there are changes to commit
if git diff --quiet scripts/setup/official-deb.sh nix/claude-desktop.nix; then
echo "No changes to scripts/setup/official-deb.sh or nix/claude-desktop.nix"
else
git add scripts/setup/official-deb.sh nix/claude-desktop.nix
git commit -m "$(cat <<COMMIT_MSG
Update Claude Desktop to version $VER
Bumped the official .deb pins (version, pool paths, SHA-256) and
the Nix SRI hashes to the newest release in Anthropic's official
APT pool.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
COMMIT_MSG
)"
git push
echo "Changes committed and pushed"
fi
# Update the version variable
gh variable set CLAUDE_DESKTOP_VERSION --body "$VER"
echo "Creating and pushing new tag: $NEW_TAG"
# Create annotated tag
git tag -a "$NEW_TAG" -m "Update to Claude Desktop $VER"
# Push the tag
git push origin "$NEW_TAG"
echo "Successfully created tag $NEW_TAG"
+989
View File
@@ -0,0 +1,989 @@
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
+54
View File
@@ -0,0 +1,54 @@
name: Cleanup Workflow Runs
run-name: |
Cleanup: ${{
github.event_name == 'schedule' && 'Weekly scheduled cleanup' ||
format('Manual cleanup by @{0}', github.actor)
}}
on:
schedule:
# Run every Thursday at midnight UTC
- cron: "0 0 * * 4"
workflow_dispatch:
jobs:
cleanup:
name: Delete Non-Release Runs
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Delete old workflow runs not tied to release tags
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
echo "Fetching workflow runs..."
# Calculate cutoff date (3 days ago)
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
echo "Deleting non-release runs older than: $cutoff"
# Get all runs where:
# - headBranch doesn't start with "v" (not a release tag)
# - createdAt is older than 3 days
runs_to_delete=$(gh run list --json databaseId,headBranch,createdAt --limit 1000 | \
jq -r --arg cutoff "$cutoff" \
'.[] | select((.headBranch | startswith("v") | not) and (.createdAt < $cutoff)) | .databaseId')
if [ -z "$runs_to_delete" ]; then
echo "No old non-release runs to delete."
exit 0
fi
count=$(echo "$runs_to_delete" | wc -l)
echo "Found $count runs to delete..."
# Delete each run
echo "$runs_to_delete" | while read -r run_id; do
echo "Deleting run $run_id..."
gh run delete "$run_id" || echo "Failed to delete run $run_id (may already be deleted)"
done
echo "Cleanup complete!"
+48
View File
@@ -0,0 +1,48 @@
name: Deploy Worker
on:
push:
branches:
- main
paths:
- 'worker/**'
- '.github/workflows/deploy-worker.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Deploy Worker
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: worker
- name: Verify route is bound and Worker responds
env:
# Must match the hostname in worker/wrangler.toml's route.
PROBE_HOST: pkg.claude-desktop-debian.dev
run: |
# Wait briefly for deploy + DNS propagation
sleep 30
# Worker proxies metadata path through to gh-pages; expect any
# 2xx/3xx. A 5xx or 521/523/530 means the route isn't bound or
# the Worker errored at edge.
status=$(curl -s -o /dev/null -w '%{http_code}' \
--max-time 30 \
"https://${PROBE_HOST}/dists/stable/InRelease")
echo "Probe status: ${status}"
if [[ ! "$status" =~ ^[23] ]]; then
echo "::error::Worker probe at ${PROBE_HOST} returned ${status}"
echo "::error::Expected 2xx or 3xx (route bound + Worker responding)"
exit 1
fi
echo "Route bound, Worker responding."
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
name: Issue Triage (v1 — manual fallback only)
run-name: |
Triage v1: #${{ inputs.issue_number }}
# v1 pipeline kept as a workflow_dispatch-only fallback. Automatic
# triggering on `issues` was removed when v2 (issue-triage-v2.yml)
# took over production routing. If v2 is ever paused or rolled back,
# re-enable the `issues: [opened, reopened]` trigger here.
#
# Kept (not deleted) because v1 uses different code paths for
# investigation and label application, which still occasionally help
# for backfilled issues the maintainer wants a second opinion on.
on:
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to triage"
required: true
type: number
permissions:
issues: write
contents: read
actions: read
concurrency:
group: issue-triage-${{ inputs.issue_number }}
cancel-in-progress: true
jobs:
# ──────────────────────────────────────────────────────────────────────
# Job 1: Gate Check — decide whether triage should proceed
# ──────────────────────────────────────────────────────────────────────
gate:
name: Gate Check
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch'
|| github.event.sender.login != 'github-actions[bot]'
outputs:
should_triage: ${{ steps.check.outputs.should_triage }}
issue_number: ${{ steps.check.outputs.issue_number }}
steps:
- name: Evaluate gate conditions
id: check
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
SENDER: ${{ github.event.sender.login }}
run: |
echo "issue_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
labels=$(gh issue view "$ISSUE_NUMBER" \
--repo "$GITHUB_REPOSITORY" \
--json labels --jq '[.labels[].name]')
# Manual dispatch bypasses all gates
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "should_triage=true" >> "$GITHUB_OUTPUT"
echo "Manual triage requested, bypassing gate checks"
exit 0
fi
# Always skip needs-human unless manually triggered
if printf '%s' "$labels" \
| jq -e 'any(. == "triage: needs-human")' >/dev/null 2>&1; then
echo "should_triage=false" >> "$GITHUB_OUTPUT"
echo "Skipping: issue requires human triage"
exit 0
fi
# Skip if already triaged (except reopened)
if [[ "$EVENT_ACTION" != "reopened" ]]; then
terminal_labels='["triage: investigated", "triage: duplicate", "triage: not-actionable"]'
if printf '%s' "$labels" \
| jq -e --argjson terms "$terminal_labels" \
'any(. as $l | $terms | any(. == $l))' >/dev/null 2>&1; then
echo "should_triage=false" >> "$GITHUB_OUTPUT"
echo "Skipping: issue already triaged"
exit 0
fi
fi
echo "should_triage=true" >> "$GITHUB_OUTPUT"
# ──────────────────────────────────────────────────────────────────────
# Job 2: Classify Issue — gather context + run Claude classification
# ──────────────────────────────────────────────────────────────────────
classify:
name: Classify Issue
runs-on: ubuntu-latest
needs: gate
if: needs.gate.outputs.should_triage == 'true'
env:
ISSUE_NUMBER: ${{ needs.gate.outputs.issue_number }}
outputs:
classification: ${{ steps.classify.outputs.classification }}
skip_comment: ${{ steps.classify.outputs.skip_comment }}
needs_investigation: ${{ steps.classify.outputs.needs_investigation }}
confidence: ${{ steps.classify.outputs.confidence }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Install Claude CLI
run: npm install -g @anthropic-ai/claude-code
- name: Gather issue context
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p /tmp/triage-context
# Fetch full issue details
gh issue view "$ISSUE_NUMBER" \
--repo "$GITHUB_REPOSITORY" \
--json number,title,body,labels,comments,author,state,createdAt \
> /tmp/triage-context/issue.json
# Extract title for searching related context
title=$(jq -r '.title' /tmp/triage-context/issue.json)
# Search for related issues (open and closed)
gh issue list \
--repo "$GITHUB_REPOSITORY" \
--search "$title" \
--state all \
--limit 10 \
--json number,title,state,labels \
> /tmp/triage-context/related-issues.json
# Search for related PRs (open and closed)
gh pr list \
--repo "$GITHUB_REPOSITORY" \
--search "$title" \
--state all \
--limit 10 \
--json number,title,state \
> /tmp/triage-context/related-prs.json
- name: Classify issue with Claude
id: classify
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
schema=$(cat .claude/scripts/schemas/triage-classify.json)
# Build prompt from template + data files (avoids shell injection)
jq -n \
--slurpfile issue /tmp/triage-context/issue.json \
--slurpfile related_issues /tmp/triage-context/related-issues.json \
--slurpfile related_prs /tmp/triage-context/related-prs.json \
--rawfile claude_md CLAUDE.md \
-r '"You are classifying a GitHub issue for the claude-desktop-debian project.\nThis project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux.\n\n## Project Context\n" + $claude_md + "\n\n## Issue\n" + ($issue[0] | tostring) + "\n\n## Related Issues\n" + ($related_issues[0] | tostring) + "\n\n## Related PRs\n" + ($related_prs[0] | tostring) + "\n\n## Label Glossary\nOnly suggest labels that accurately apply. Here is what each label means:\n- bug: Confirmed or likely software defect in THIS project (packaging, patching, build scripts)\n- enhancement: New feature request or improvement to this project\n- question: Usage question, not a bug or feature request\n- duplicate: This issue duplicates another existing issue\n- regression: Previously working functionality that broke in a newer release\n- security: Security-related issue (always set skip_comment=true for these)\n- cowork: Related to Cowork mode ONLY — the VM-based Claude Code session feature launched from the desktop app Code tab. Do NOT use for general Code tab issues or session history issues.\n- mcp: Related to MCP (Model Context Protocol) server/plugin integration\n- blocked: Waiting on an external dependency to be resolved\n- needs reproduction: Cannot reproduce, need more info from reporter\n- platform: amd64 / platform: arm64: Issue is specific to one CPU architecture\n- format: deb / format: appimage / format: rpm / format: nix: Issue is specific to one package format\n- priority: critical: Blocks usage for most users\n- priority: high: Important, should be addressed soon\n- priority: medium: Should be addressed when possible\n- priority: low: Nice to have, not urgent\n\n## Instructions\n1. Read the issue carefully. Consider the title, body, and any comments.\n2. Check the related issues and PRs for duplicates or prior discussion.\n3. Classify the issue into one of: bug, feature, question, duplicate, needs-info, not-actionable, needs-human.\n4. Set skip_comment to true if: classification is needs-human, you have low confidence on a complex or sensitive issue, or the issue involves security concerns.\n5. Set needs_source_investigation to true only if understanding the original Claude Desktop JavaScript source would help investigate.\n6. Suggest additional labels from the Label Glossary above. Only apply labels you are confident are correct.\n7. If classifying as duplicate, set duplicate_of to the issue number.\n8. If classifying as needs-info, list specific questions to ask."' \
> /tmp/classify-prompt.txt
result=$(claude -p "$(cat /tmp/classify-prompt.txt)" \
--output-format json \
--json-schema "$schema" \
--model claude-sonnet-4-6 \
--max-budget-usd 2.00 \
2>/dev/null) || {
echo "::error::Claude classification failed"
exit 1
}
# Extract structured output (key is .structured_output per claude CLI)
structured=$(printf '%s' "$result" \
| jq -c '.structured_output // empty' 2>/dev/null)
if [[ -z "$structured" ]]; then
echo "::error::No structured output from classification"
exit 1
fi
printf '%s' "$structured" > /tmp/triage-context/classification.json
classification=$(jq -r '.classification' /tmp/triage-context/classification.json)
skip_comment=$(jq -r '.skip_comment // false' /tmp/triage-context/classification.json)
needs_investigation=$(jq -r '.needs_source_investigation' \
/tmp/triage-context/classification.json)
confidence=$(jq -r '.confidence // "medium"' /tmp/triage-context/classification.json)
{
echo "classification=$classification"
echo "skip_comment=$skip_comment"
echo "needs_investigation=$needs_investigation"
echo "confidence=$confidence"
} >> "$GITHUB_OUTPUT"
echo "Classification: $classification (skip=$skip_comment, investigate=$needs_investigation, confidence=$confidence)"
- name: Upload triage context
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: triage-context
path: /tmp/triage-context/
retention-days: 1
# ──────────────────────────────────────────────────────────────────────
# Job 3: Fetch Reference Source — download beautified original source
# ──────────────────────────────────────────────────────────────────────
fetch-reference:
name: Fetch Reference Source
runs-on: ubuntu-latest
needs: classify
if: >-
needs.classify.outputs.needs_investigation == 'true'
&& needs.classify.outputs.skip_comment != 'true'
steps:
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Install extraction tools
run: npm install -g @electron/asar prettier
- name: Download amd64 AppImage from latest release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download \
--repo "$GITHUB_REPOSITORY" \
--pattern '*amd64*.AppImage' \
--skip-existing \
--dir /tmp/ref-source || {
echo "::error::Could not download AppImage from latest release"
exit 1
}
appimage=$(find /tmp/ref-source -name '*amd64*.AppImage' ! -name '*.zsync' | head -1)
if [[ -z "$appimage" ]]; then
echo "::error::No amd64 AppImage found in release assets"
exit 1
fi
echo "Downloaded: $appimage"
- name: Extract and beautify reference source
run: |
appimage=$(find /tmp/ref-source -name '*amd64*.AppImage' ! -name '*.zsync' | head -1)
chmod +x "$appimage"
cd /tmp/ref-source
"$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 "::error::app.asar not found in AppImage"
exit 1
fi
asar extract "$asar_path" app-extracted
echo "Extracted contents (top-level):"
ls -la app-extracted/
echo ""
if [[ -d app-extracted/.vite/build ]]; then
echo "Beautifying JS files in .vite/build/:"
ls app-extracted/.vite/build/*.js
npx prettier --write "app-extracted/.vite/build/*.js"
else
echo "::warning::.vite/build/ directory not found in extracted source"
find app-extracted -name '*.js' -not -path '*/node_modules/*' | head -20
fi
echo ""
echo "Total files: $(find app-extracted -type f | wc -l)"
- name: Upload reference source
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: reference-source
path: /tmp/ref-source/app-extracted/
include-hidden-files: true
retention-days: 1
# ──────────────────────────────────────────────────────────────────────
# Job 4: Investigate Source — deep-dive with Claude into repo + ref
# ──────────────────────────────────────────────────────────────────────
investigate:
name: Investigate Source
runs-on: ubuntu-latest
needs: [classify, fetch-reference]
if: needs.fetch-reference.result == 'success'
outputs:
has_findings: ${{ steps.investigate.outputs.has_findings }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Install Claude CLI
run: npm install -g @anthropic-ai/claude-code
- name: Download triage context
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: triage-context
path: /tmp/triage-context/
- name: Download reference source
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: reference-source
path: /tmp/ref-source/app-extracted/
- name: Verify reference source
run: |
echo "Reference source contents:"
ls -la /tmp/ref-source/app-extracted/
if [[ -d /tmp/ref-source/app-extracted/.vite/build ]]; then
echo "Key JS files:"
ls -la /tmp/ref-source/app-extracted/.vite/build/*.js
else
echo "::warning::.vite/build/ not found in downloaded artifact"
fi
- name: Investigate with Claude
id: investigate
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Build investigation prompt from files (avoids shell injection)
{
cat << 'PREAMBLE'
Investigate the following GitHub issue for the claude-desktop-debian project.
## Classification
PREAMBLE
cat /tmp/triage-context/classification.json
echo ""
echo "## Investigation Hints"
jq -r '.investigation_hints // "None"' /tmp/triage-context/classification.json
cat << CONTEXT
The project repository is at $(pwd). Search the source code for relevant patterns.
The beautified reference source (original app.asar) is at /tmp/ref-source/app-extracted/.
Key files: .vite/build/index.js (main-process entry stub; since 1.19367.0 it require()s the code-split main chunk), .vite/build/index.chunk-<hash>.js (main process — the real code), .vite/build/mainWindow.js, .vite/build/mainView.js.
## Project Documentation
CONTEXT
cat CLAUDE.md
cat << 'BODY'
## How This Project Patches Upstream Code
IMPORTANT: All fixes to the original JavaScript are applied via sed/regex in scripts/patches/*.sh.
Each subsystem owns its own file — tray.sh, cowork.sh, claude-code.sh, quick-window.sh,
titlebar.sh, app-asar.sh — with shared helpers in scripts/patches/_common.sh.
build.sh is a ~300-line orchestrator that sources these modules in order.
Variable and function names are MINIFIED and change between releases.
Patches must use regex patterns that match both minified and beautified spacing.
Variable names are extracted dynamically with grep -oP, never hardcoded.
See scripts/patches/*.sh for examples of existing patches (search for patch_ functions).
The wrapper files (frame-fix-wrapper.js, frame-fix-entry.js) intercept require('electron')
and can patch BrowserWindow defaults without touching minified code.
## Investigation Rules
### All bugs are ours to fix
This project's goal is to take a working Anthropic product and make it work
on Linux. Every bug is something we can investigate and potentially patch.
Check scripts/patches/*.sh first for bugs in patched areas (cowork.sh for cowork,
tray.sh for tray, titlebar.sh or quick-window.sh for window decorations, app-asar.sh
for platform checks / frame). Read the relevant patch_ function and trace what it
modifies. If a behavior difference exists between Windows/macOS and our Linux build,
that is a gap in our patching.
### Verify before stating
Only state facts you verified by reading actual code or running commands.
Never claim code exists, functions behave a certain way, or patterns match
without finding them in the source. If you cannot find evidence, say so
explicitly rather than speculating.
### Validate network assumptions
For download, CDN, or network-related issues, use curl to verify URLs
actually exist before speculating about failures. For example:
curl -sI "https://example.com/file" | head -5
Check HTTP status codes rather than assuming 404 or success.
## Output Format
Structure your response in these sections:
### Findings
Concise root cause analysis. Be specific about file paths and line numbers.
### Relevant Code
Include the actual code snippets relevant to diagnosing or fixing this issue.
For each snippet, include:
- The file path and line numbers
- The code block itself
- A one-line note on why it matters
### Patch Approach
If a fix is feasible, provide everything an agent needs to implement it:
- The exact anchor strings or regex patterns to locate the target code in minified source
- What the sed replacement should do (insert, wrap, modify)
- Any variable names that need dynamic extraction (with the grep -oP pattern to extract them)
- Whether the fix belongs in scripts/patches/*.sh (sed patch) or frame-fix-wrapper.js (Electron intercept)
- Surrounding context (what comes before/after the target) to make the regex unique
The goal is to give enough context that an agent can write the patch without re-reading the source.
BODY
} > /tmp/investigate-prompt.txt
investigation=$(claude -p "$(cat /tmp/investigate-prompt.txt)" \
--dangerously-skip-permissions \
--model claude-sonnet-4-6 \
--max-budget-usd 3.00 \
2>/dev/null) || {
echo "::warning::Investigation failed"
echo "has_findings=false" >> "$GITHUB_OUTPUT"
exit 0
}
# Handle both JSON and plain text output
if printf '%s' "$investigation" | jq -e '.result' >/dev/null 2>&1; then
printf '%s' "$investigation" | jq -r '.result' > /tmp/investigation.txt
else
printf '%s' "$investigation" > /tmp/investigation.txt
fi
if [[ -s /tmp/investigation.txt ]]; then
echo "has_findings=true" >> "$GITHUB_OUTPUT"
else
echo "has_findings=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload investigation findings
if: steps.investigate.outputs.has_findings == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: investigation-findings
path: /tmp/investigation.txt
retention-days: 1
# ──────────────────────────────────────────────────────────────────────
# Job 5: Fetch Voice Profile — download aaddrick's writing style guide
# ──────────────────────────────────────────────────────────────────────
fetch-voice:
name: Fetch Voice Profile
runs-on: ubuntu-latest
needs: classify
if: needs.classify.outputs.skip_comment != 'true'
steps:
- name: Download voice profile
run: |
curl -fsSL \
"https://raw.githubusercontent.com/aaddrick/written-voice-replication/master/.claude/agents/aaddrick-voice.md" \
-o /tmp/voice-profile.md
- name: Upload voice profile
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: voice-profile
path: /tmp/voice-profile.md
retention-days: 1
# ──────────────────────────────────────────────────────────────────────
# Job 6: Write Comment — generate and post triage comment
# ──────────────────────────────────────────────────────────────────────
comment:
name: Write Comment
runs-on: ubuntu-latest
needs: [classify, investigate, fetch-voice]
if: >-
always()
&& needs.classify.result == 'success'
&& needs.classify.outputs.skip_comment != 'true'
&& needs.investigate.result != 'cancelled'
&& needs.fetch-voice.result != 'cancelled'
outputs:
comment_posted: ${{ steps.post.outputs.comment_posted }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Install Claude CLI
run: npm install -g @anthropic-ai/claude-code
- name: Download triage context
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: triage-context
path: /tmp/triage-context/
- name: Download investigation findings
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: investigation-findings
path: /tmp/investigation/
- name: Download voice profile
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: voice-profile
path: /tmp/voice/
- name: Generate triage comment
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Build comment prompt from files (avoids shell expansion issues)
{
cat << 'HEADER'
Generate a triage comment for this GitHub issue.
## Writing Voice (CRITICAL — follow this exactly)
The following voice profile defines HOW you write. Match this voice
precisely. Every aspect of tone, sentence structure, and word choice
must follow this profile. This is the most important instruction.
HEADER
echo ""
if [[ -f /tmp/voice/voice-profile.md ]]; then
cat /tmp/voice/voice-profile.md
fi
echo ""
echo "## Project Context"
cat CLAUDE.md
echo ""
echo "## Classification"
cat /tmp/triage-context/classification.json
echo ""
echo "## Related Issues"
cat /tmp/triage-context/related-issues.json
echo ""
echo "## Related PRs"
cat /tmp/triage-context/related-prs.json
echo ""
echo "## Investigation Findings"
if [[ -f /tmp/investigation/investigation.txt ]]; then
cat /tmp/investigation/investigation.txt
echo ""
echo "NOTE: The investigation includes relevant code samples. When useful, include key snippets in the comment to help whoever picks up the fix. Don't dump everything — just the code that clarifies the root cause or shows where a patch would go."
else
echo "No investigation performed."
fi
echo ""
cat << 'INSTRUCTIONS'
## Formatting Constraints
- This is an automated one-shot triage comment. You will NOT be part of any follow-up conversation. Do not ask the reporter to share output with you, do not offer to write fixes, do not imply you will respond again. Write as if leaving a final note.
- Every bug is ours to investigate and fix. Frame findings in terms of what could be patched. Never dismiss an issue as someone else's problem.
- Lead with the finding, then reasoning
- Keep to 2-4 short paragraphs
- Use code blocks or links where helpful
- Reference related issues/PRs if they provide useful context (use #NNN format)
- Don't open with "Thank you for your report" or similar
- Don't overpromise fixes or timelines
- If the classification is "duplicate", link to the duplicate issue
- If "needs-info", ask the specific questions from the classification
- Output ONLY the comment text, no wrapping or explanation. Do not ask for approval, confirmation, or permission. Your output will be posted directly.
- End with this exact attribution block:
---
*This is an automated triage comment. A maintainer will review this issue and may provide further guidance.*
Written by Claude Sonnet via [Claude Code](https://claude.ai/code)
INSTRUCTIONS
} > /tmp/comment-prompt.txt
comment_result=$(claude -p "$(cat /tmp/comment-prompt.txt)" \
--dangerously-skip-permissions \
--model claude-sonnet-4-6 \
--max-budget-usd 2.00 \
2>/dev/null) || {
echo "::error::Comment generation failed"
exit 1
}
# Handle both JSON (.result key) and plain text output
if printf '%s' "$comment_result" | jq -e '.result' >/dev/null 2>&1; then
printf '%s' "$comment_result" | jq -r '.result' > /tmp/comment.md
else
printf '%s' "$comment_result" > /tmp/comment.md
fi
- name: Post comment
id: post
env:
GH_TOKEN: ${{ github.token }}
run: |
issue_num=$(jq -r '.number' /tmp/triage-context/issue.json)
if [[ -s /tmp/comment.md ]]; then
gh issue comment "$issue_num" \
--repo "$GITHUB_REPOSITORY" \
--body-file /tmp/comment.md
echo "comment_posted=true" >> "$GITHUB_OUTPUT"
echo "Posted triage comment on issue #$issue_num"
else
echo "comment_posted=false" >> "$GITHUB_OUTPUT"
echo "::warning::Comment file is empty, skipping post"
fi
# ──────────────────────────────────────────────────────────────────────
# Job 7: Apply Labels — triage label + suggested labels (LAST)
# ──────────────────────────────────────────────────────────────────────
label:
name: Apply Labels
runs-on: ubuntu-latest
needs: [classify, comment]
if: >-
always()
&& needs.classify.result == 'success'
steps:
- name: Download triage context
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: triage-context
path: /tmp/triage-context/
- name: Apply triage and suggested labels
env:
GH_TOKEN: ${{ github.token }}
run: |
issue_num=$(jq -r '.number' /tmp/triage-context/issue.json)
classification=$(jq -r '.classification' /tmp/triage-context/classification.json)
# Remove old triage labels and needs triage
for label in \
"triage: investigated" \
"triage: needs-info" \
"triage: duplicate" \
"triage: not-actionable" \
"triage: needs-human" \
"needs triage"; do
gh issue edit "$issue_num" \
--repo "$GITHUB_REPOSITORY" \
--remove-label "$label" 2>/dev/null || true
done
# Map classification to triage label
case "$classification" in
bug|feature|question)
triage_label="triage: investigated"
;;
duplicate|needs-info|not-actionable|needs-human)
triage_label="triage: $classification"
;;
*)
triage_label="triage: needs-human"
;;
esac
gh issue edit "$issue_num" \
--repo "$GITHUB_REPOSITORY" \
--add-label "$triage_label"
# Apply additional suggested labels
suggested=$(jq -r '.suggested_labels[]? // empty' \
/tmp/triage-context/classification.json 2>/dev/null)
while IFS= read -r label; do
if [[ -n "$label" ]]; then
gh issue edit "$issue_num" \
--repo "$GITHUB_REPOSITORY" \
--add-label "$label" 2>/dev/null || true
fi
done <<< "$suggested"
echo "Applied triage label: $triage_label"
@@ -0,0 +1,71 @@
# Records the newest claude-desktop version in Anthropic's official APT
# pool once a day, on the `official-pool-log` branch. The log answers the
# rebase go/no-go question "does the official Linux pool track the release
# train closely enough to rebase on?" with data instead of optimism.
# See docs/learnings/official-deb-rebase-verification.md.
name: Official Pool Recorder
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: official-pool-recorder
cancel-in-progress: false
jobs:
record:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve newest official versions
id: resolve
run: |
source scripts/_common.sh
source scripts/setup/official-deb.sh
resolve_official_deb amd64
echo "amd64=$resolved_official_version" >> "$GITHUB_OUTPUT"
resolve_official_deb arm64
echo "arm64=$resolved_official_version" >> "$GITHUB_OUTPUT"
- name: Append to log branch
env:
AMD64_VERSION: ${{ steps.resolve.outputs.amd64 }}
ARM64_VERSION: ${{ steps.resolve.outputs.arm64 }}
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
if git fetch origin official-pool-log:official-pool-log; then
git checkout official-pool-log
else
git checkout --orphan official-pool-log
git rm -rf --quiet . || true
printf 'date\tamd64\tarm64\n' > official-pool-versions.tsv
fi
last=$(tail -n1 official-pool-versions.tsv | cut -f2,3)
new=$(printf '%s\t%s' "$AMD64_VERSION" "$ARM64_VERSION")
if [[ "$last" == "$new" ]]; then
echo 'No new pool version; nothing to record.'
exit 0
fi
printf '%s\t%s\n' "$(date -u +%F)" "$new" \
>> official-pool-versions.tsv
git add official-pool-versions.tsv
git commit -m "record official pool versions $(date -u +%F)"
for _ in 1 2 3; do
if git push origin official-pool-log; then
exit 0
fi
git pull --rebase origin official-pool-log
done
echo 'Failed to push after 3 attempts' >&2
exit 1
+32
View File
@@ -0,0 +1,32 @@
---
name: Shellcheck
run-name: |
Shellcheck: ${{
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]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
shellcheck:
name: Check shell scripts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install dependencies
run: |
sudo apt update && sudo apt install -y shellcheck
- name: shellcheck
run: |
git grep -l '^#\( *shellcheck \|!\(/bin/\|/usr/bin/env \)\(sh\|bash\|dash\|ksh\)\)' -- '*.sh' | xargs shellcheck -x
+89
View File
@@ -0,0 +1,89 @@
name: Test Build Artifacts (Reusable)
on:
workflow_call:
inputs:
arch:
description: Architecture of the artifacts under test (amd64/arm64)
type: string
default: amd64
permissions:
contents: read
jobs:
test-artifact:
strategy:
fail-fast: false
matrix:
include:
- format: deb
container: ""
- format: rpm
container: "fedora:42"
- format: appimage
container: ""
name: Validate ${{ inputs.arch }} ${{ matrix.format }} package
# arm64 artifacts run on a native arm64 runner (matching build-arm64)
# so the launch smoke test actually executes the packaged binary
# rather than failing on a foreign architecture.
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-latest' }}
container: ${{ matrix.container || '' }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: package-${{ inputs.arch }}-${{ matrix.format }}
path: artifacts/
- name: Install test dependencies (Fedora)
if: matrix.format == 'rpm'
# Electron's shared libraries (nss/nspr/gtk3/X11/etc.) must be
# installed explicitly: the rpm is installed with `rpm -ivh --nodeps`
# and its spec sets `AutoReqProv: no`, so the package declares no
# runtime Requires and nothing pulls these in. Without them the
# launch smoke test dies with `libnspr4.so: cannot open shared
# object file` (exit 127). The Ubuntu runner already carries them.
run: |
dnf install -y findutils file nodejs npm \
xorg-x11-server-Xvfb dbus-daemon util-linux procps-ng \
nss nspr atk at-spi2-atk at-spi2-core cups-libs gtk3 \
libdrm mesa-libgbm alsa-lib libX11 libXcomposite libXdamage \
libXext libXfixes libXrandr libxcb libxkbcommon pango cairo \
libXScrnSaver libXtst libxshmfence
- name: Install test dependencies (Ubuntu)
if: matrix.format != 'rpm'
run: |
sudo apt-get update
sudo apt-get install -y file libfuse2 nodejs npm \
xvfb dbus-x11 procps
# Fail loud if a smoke-test tool is missing. Without this guard a
# missing/renamed tool turns run_launch_smoke_test into a silent
# green skip (it does `pass "$skip"; return`), masking the test.
- name: Verify smoke-test tools are present (Ubuntu)
if: matrix.format != 'rpm'
run: |
for t in xvfb-run dbus-run-session setsid; do
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
done
- name: Verify smoke-test tools are present (Fedora)
if: matrix.format == 'rpm'
run: |
for t in xvfb-run dbus-run-session setsid runuser; do
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
done
- name: Run artifact tests
env:
TARGET_ARCH: ${{ inputs.arch }}
run: |
chmod +x tests/test-artifact-${{ matrix.format }}.sh
tests/test-artifact-${{ matrix.format }}.sh artifacts/
+161
View File
@@ -0,0 +1,161 @@
name: Test Build Script Flags (Reusable)
on:
workflow_call: # Make this workflow reusable
workflow_dispatch: # Allows manual triggering for testing
concurrency:
group: test-flags-${{ github.ref }}
# Matches ci.yml: queue rather than cancel, so a reusable invocation
# from an in-flight CI run isn't killed mid-flight on the next push.
cancel-in-progress: false
jobs:
test-flags:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# FUSE install removed - not needed for --test-flags
- name: Make build script executable
run: chmod +x ./build.sh
# Test Case 1: Defaults (deb, clean=yes)
- name: Test Case 1 - Defaults (deb, clean=yes)
run: |
echo "--- Running Test Case 1: ./build.sh --test-flags ---"
OUTPUT=$(./build.sh --test-flags)
echo "$OUTPUT" # Print output for logs
echo "--- Verifying Test Case 1 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 1 Passed"
# No cleanup needed
# Test Case 2: --build deb (clean=yes)
- name: Test Case 2 - --build deb (clean=yes)
run: |
echo "--- Running Test Case 2: ./build.sh --build deb --test-flags ---"
OUTPUT=$(./build.sh --build deb --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 2 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 2 Passed"
# No cleanup needed
# Test Case 3: --build appimage (clean=yes)
- name: Test Case 3 - --build appimage (clean=yes)
run: |
echo "--- Running Test Case 3: ./build.sh --build appimage --test-flags ---"
OUTPUT=$(./build.sh --build appimage --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 3 ---"
echo "$OUTPUT" | grep -q "Build Format: appimage" || (echo "❌ Expected 'Build Format: appimage'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 3 Passed"
# No cleanup needed
# Test Case 4: --clean yes (build=deb)
- name: Test Case 4 - --clean yes (build=deb)
run: |
echo "--- Running Test Case 4: ./build.sh --clean yes --test-flags ---"
OUTPUT=$(./build.sh --clean yes --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 4 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 4 Passed"
# No cleanup needed
# Test Case 5: --clean no (build=deb)
- name: Test Case 5 - --clean no (build=deb)
run: |
echo "--- Running Test Case 5: ./build.sh --clean no --test-flags ---"
OUTPUT=$(./build.sh --clean no --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 5 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: no" || (echo "❌ Expected 'Clean Action: no'" && exit 1)
echo "✓ Test Case 5 Passed"
# No cleanup needed
# Test Case 6: --build deb --clean yes
- name: Test Case 6 - --build deb --clean yes
run: |
echo "--- Running Test Case 6: ./build.sh --build deb --clean yes --test-flags ---"
OUTPUT=$(./build.sh --build deb --clean yes --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 6 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 6 Passed"
# No cleanup needed
# Test Case 7: --build deb --clean no
- name: Test Case 7 - --build deb --clean no
run: |
echo "--- Running Test Case 7: ./build.sh --build deb --clean no --test-flags ---"
OUTPUT=$(./build.sh --build deb --clean no --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 7 ---"
echo "$OUTPUT" | grep -q "Build Format: deb" || (echo "❌ Expected 'Build Format: deb'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: no" || (echo "❌ Expected 'Clean Action: no'" && exit 1)
echo "✓ Test Case 7 Passed"
# No cleanup needed
# Test Case 8: --build appimage --clean yes
- name: Test Case 8 - --build appimage --clean yes
run: |
echo "--- Running Test Case 8: ./build.sh --build appimage --clean yes --test-flags ---"
OUTPUT=$(./build.sh --build appimage --clean yes --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 8 ---"
echo "$OUTPUT" | grep -q "Build Format: appimage" || (echo "❌ Expected 'Build Format: appimage'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: yes" || (echo "❌ Expected 'Clean Action: yes'" && exit 1)
echo "✓ Test Case 8 Passed"
# No cleanup needed
# Test Case 9: --build appimage --clean no
- name: Test Case 9 - --build appimage --clean no
run: |
echo "--- Running Test Case 9: ./build.sh --build appimage --clean no --test-flags ---"
OUTPUT=$(./build.sh --build appimage --clean no --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 9 ---"
echo "$OUTPUT" | grep -q "Build Format: appimage" || (echo "❌ Expected 'Build Format: appimage'" && exit 1)
echo "$OUTPUT" | grep -q "Clean Action: no" || (echo "❌ Expected 'Clean Action: no'" && exit 1)
echo "✓ Test Case 9 Passed"
# No cleanup needed
# Test Case 10: --arch arm64 overrides the detected architecture
# (cross-build support; the runner itself is amd64). The last
# "Target Architecture" line is the authoritative post-override one
# from parse_arguments; detect_architecture's earlier line reports
# the raw host detection by design.
- name: Test Case 10 - --arch arm64 override
run: |
echo "--- Running Test Case 10: ./build.sh --arch arm64 --build appimage --test-flags ---"
OUTPUT=$(./build.sh --arch arm64 --build appimage --test-flags)
echo "$OUTPUT"
echo "--- Verifying Test Case 10 ---"
echo "$OUTPUT" | grep "Target Architecture:" | tail -1 | grep -q "arm64" || (echo "❌ Expected final 'Target Architecture: arm64'" && exit 1)
echo "$OUTPUT" | grep -q "Build Format: appimage" || (echo "❌ Expected 'Build Format: appimage'" && exit 1)
echo "✓ Test Case 10 Passed"
# Test Case 11: --arch rejects invalid values
- name: Test Case 11 - --arch rejects invalid values
run: |
echo "--- Running Test Case 11: ./build.sh --arch bogus --test-flags ---"
if OUTPUT=$(./build.sh --arch bogus --test-flags 2>&1); then
echo "$OUTPUT"
echo "❌ Expected non-zero exit for invalid --arch"
exit 1
fi
echo "$OUTPUT"
echo "--- Verifying Test Case 11 ---"
echo "$OUTPUT" | grep -q "Invalid architecture specified" || (echo "❌ Expected invalid-architecture error message" && exit 1)
echo "✓ Test Case 11 Passed"
+56
View File
@@ -0,0 +1,56 @@
name: BATS Tests
run-name: |
BATS: ${{
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:
- "tests/**"
- "scripts/**"
- ".github/workflows/tests.yml"
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bats-${{ github.ref }}
cancel-in-progress: true
jobs:
bats:
name: BATS unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install BATS and Node.js
run: |
sudo apt-get update
sudo apt-get install -y bats nodejs
- name: Run BATS test suite
# Cowork tests load scripts/cowork-fallback/cowork-vm-service.js
# via `node` — the `nodejs` install above is what they need.
# The cowork-fallback suites cover the bwrap fallback daemon and
# its asar patch, which now ship in every package (they were
# exempt while the code shipped in no artifact).
run: |
bats --print-output-on-failure \
tests/*.bats scripts/cowork-fallback/tests/*.bats
- name: Chromium switch-list smoke
# Fails if the launcher's effective Chromium switch list drifts
# from tools/chromium-switches.baseline without a deliberate
# update (every upstream bump / launcher PR is checked).
run: ./tools/chromium-switch-smoke.sh
+49
View File
@@ -0,0 +1,49 @@
name: Update Flake Lock
on:
schedule:
- cron: "0 2 * * 1" # Monday 2 AM UTC
workflow_dispatch:
permissions:
contents: write
concurrency:
group: main-branch-auto-update
cancel-in-progress: false
jobs:
update-flake-lock:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GH_PAT }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21
- name: Update flake.lock
run: nix flake update --flake .
- name: Check for changes
id: check
run: |
if git diff --quiet flake.lock; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "flake.lock is already up to date"
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "flake.lock has changes:"
nix flake metadata --json | jq -r '.locks.nodes | to_entries[] | select(.key != "root") | "\(.key): \(.value.locked.rev[0:8])"'
fi
- name: Commit and push
if: steps.check.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "chore: update flake.lock"
git push