Files
Aperant/.github/workflows/release.yml
T
Andy 52a4fcc6d3 fix(ci): add Rust toolchain for Intel Mac builds (#459)
* fix(ci): add Rust toolchain for Intel Mac builds

real_ladybug package has no pre-built wheel for macOS Intel (x64),
requiring compilation from source. The build was hanging because
the Rust toolchain was not installed.

This adds dtolnay/rust-action@stable to the Intel Mac build jobs
in both release and beta-release workflows.

Also updates cache key to invalidate old caches that may have
incomplete packages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct rust-toolchain action name (not rust-action)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 21:17:36 +01:00

637 lines
23 KiB
YAML

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Test build without creating release'
required: false
default: true
type: boolean
jobs:
# Intel build on Intel runner for native compilation
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
build-macos-intel:
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
- name: Package macOS (Intel)
run: cd apps/frontend && npm run package:mac -- --x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
- name: Notarize macOS Intel app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-intel-builds
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/frontend && npm run build
- name: Package macOS (Apple Silicon)
run: cd apps/frontend && npm run package:mac -- --arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
- name: Notarize macOS ARM64 app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-arm64-builds
path: |
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
- name: Package Windows
run: cd apps/frontend && npm run package:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
apps/frontend/dist/*.exe
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Flatpak
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/frontend && npm run build
- name: Package Linux
run: cd apps/frontend && npm run package:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.flatpak
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \;
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
exit 1
fi
echo "Found $artifact_count artifact(s):"
ls -la release-assets/
- name: Generate checksums
run: |
cd release-assets
sha256sum ./* > checksums.sha256
cat checksums.sha256
- name: Scan with VirusTotal
id: virustotal
continue-on-error: true
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
if [ -z "$VT_API_KEY" ]; then
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
echo "vt_results=" >> $GITHUB_OUTPUT
exit 0
fi
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
echo "Scanning $filename (${filesize} bytes)..."
# For files > 32MB, get a special upload URL first
if [ "$filesize" -gt 33554432 ]; then
echo " Large file detected, requesting upload URL..."
upload_url_response=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/files/upload_url" \
--header "x-apikey: $VT_API_KEY")
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
if [ -z "$upload_url" ]; then
echo "::warning::Failed to get upload URL for large file $filename"
echo "Response: $upload_url_response"
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
continue
fi
api_url="$upload_url"
else
api_url="https://www.virustotal.com/api/v3/files"
fi
# Upload file to VirusTotal
response=$(curl -s --request POST \
--url "$api_url" \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
# Check if response is valid JSON before parsing
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
echo "::warning::VirusTotal returned invalid JSON for $filename"
echo "Response (first 500 chars): ${response:0:500}"
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
continue
fi
# Check for API error response
error_code=$(echo "$response" | jq -r '.error.code // empty')
if [ -n "$error_code" ]; then
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
continue
fi
# Extract analysis ID
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename to VirusTotal"
echo "Response: $response"
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
continue
fi
echo "Uploaded $filename, analysis ID: $analysis_id"
# Wait for analysis to complete (max 5 minutes per file)
analysis=""
for i in {1..30}; do
sleep 10
analysis=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
# Validate JSON response
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
echo " Warning: Invalid JSON response on attempt $i, retrying..."
continue
fi
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
echo " Status: $status (attempt $i/30)"
if [ "$status" = "completed" ]; then
break
fi
done
# Final validation that we have valid analysis data
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
echo "::warning::Could not get complete analysis for $filename, using local hash"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
continue
fi
# Get file hash for permanent URL
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
if [ -z "$file_hash" ]; then
# Fallback: calculate hash locally
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
fi
# Get detection stats
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
vt_url="https://www.virustotal.com/gui/file/$file_hash"
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
else
echo "$filename is clean ($undetected engines, 0 detections)"
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
fi
done
echo "" >> vt_results.md
# Save results for release notes
cat vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Dry run summary
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
run: |
echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
ls -la release-assets/ >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "### Checksums" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Generate changelog
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
id: changelog
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
uses: softprops/action-gh-release@v2
with:
body: |
${{ steps.changelog.outputs.body }}
${{ steps.virustotal.outputs.vt_results }}
files: release-assets/*
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Update README with new version after successful release
update-readme:
needs: [create-release]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version and detect release type
id: version
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
if [[ "$VERSION" == *-* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Detected PRERELEASE: $VERSION"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Detected STABLE release: $VERSION"
fi
- name: Update README.md
run: |
python3 << 'EOF'
import re
import sys
version = "${{ steps.version.outputs.version }}"
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
# Shields.io escapes hyphens as --
version_badge = version.replace("-", "--")
# Read README
with open("README.md", "r") as f:
content = f.read()
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
# Shields.io escaped pattern (hyphens as --)
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
def update_section(text, start_marker, end_marker, replacements):
"""Update content between markers with given replacements."""
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
def replace_section(match):
section = match.group(2)
for old_pattern, new_value in replacements:
section = re.sub(old_pattern, new_value, section)
return match.group(1) + section + match.group(3)
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
if is_prerelease:
print(f"Updating BETA section to {version} (badge: {version_badge})")
# Update beta badge
content = re.sub(
rf'beta-{semver_badge}-orange',
f'beta-{version_badge}-orange',
content
)
# Update beta version badge link
content = update_section(content,
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update beta downloads
content = update_section(content,
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
else:
print(f"Updating STABLE section to {version} (badge: {version_badge})")
# Update top version badge
content = update_section(content,
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
[
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
(rf'tag/v{semver}\)', f'tag/v{version})'),
])
# Update stable badge
content = re.sub(
rf'stable-{semver_badge}-blue',
f'stable-{version_badge}-blue',
content
)
# Update stable version badge link
content = update_section(content,
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update stable downloads
content = update_section(content,
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
# Write updated README
with open("README.md", "w") as f:
f.write(content)
print(f"README.md updated for {version} (prerelease={is_prerelease})")
EOF
echo "--- Verifying update ---"
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
- name: Commit and push README update
run: |
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 README.md; then
echo "No changes to README.md, skipping commit"
exit 0
fi
git add README.md
git commit -m "docs: update README to v${{ steps.version.outputs.version }} [skip ci]"
git push origin main