Skip to content

Release 1.1.4

Release 1.1.4 #48

Workflow file for this run

name: Release Desktop App
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "v-prefixed release tag to create or update, for example v1.0.2"
required: true
type: string
permissions:
contents: write
concurrency:
group: release-desktop-${{ inputs.release_tag || github.ref_name }}
cancel-in-progress: false
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS Apple Silicon
os: macos-14
platform: mac
desktop-platform: darwin
desktop-arch: aarch64
args: --target aarch64-apple-darwin --bundles app,dmg --config '{"bundle":{"createUpdaterArtifacts":true}}'
rust-targets: aarch64-apple-darwin
sidecar-target: aarch64-apple-darwin
sidecar-ext: ""
desktop-smoke-timeout: "30"
- name: macOS Intel
os: macos-15-intel
platform: mac
desktop-platform: darwin
desktop-arch: x86_64
args: --target x86_64-apple-darwin --bundles app,dmg --config '{"bundle":{"createUpdaterArtifacts":true}}'
rust-targets: x86_64-apple-darwin
sidecar-target: x86_64-apple-darwin
sidecar-ext: ""
desktop-smoke-timeout: "90"
- name: Linux
os: ubuntu-22.04
platform: linux
desktop-platform: linux
desktop-arch: x86_64
args: --config '{"bundle":{"createUpdaterArtifacts":true}}'
rust-targets: ""
sidecar-target: x86_64-unknown-linux-gnu
sidecar-ext: ""
desktop-smoke-timeout: "30"
- name: Windows
os: windows-latest
platform: windows
desktop-platform: windows
desktop-arch: x86_64
args: --config '{"bundle":{"createUpdaterArtifacts":true}}'
rust-targets: ""
sidecar-target: x86_64-pc-windows-msvc
sidecar-ext: ".exe"
desktop-smoke-timeout: "90"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "20.19.0"
cache: npm
cache-dependency-path: apps/package-lock.json
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Verify release version matches tag
shell: bash
env:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
python scripts/verify_release_version.py --expected "$RELEASE_TAG" --require-v-prefix
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- name: Install Linux system dependencies
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf rpm cpio xvfb
- name: Install app dependencies
working-directory: apps
run: npm ci
- name: Build bundled backend sidecar
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[desktop]"
python scripts/build-desktop-sidecar.py --target ${{ matrix.sidecar-target }}
- name: Smoke test bundled backend sidecar
shell: bash
run: |
set -euo pipefail
mkdir -p smoke-reports
report="smoke-reports/backend-sidecar-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-backend-sidecar.py \
"apps/desktop/src-tauri/binaries/cc-branch-backend-${{ matrix.sidecar-target }}${{ matrix.sidecar-ext }}" \
--expected-version "${{ inputs.release_tag || github.ref_name }}" \
--expected-platform "${{ matrix.desktop-platform }}" \
--expected-arch "${{ matrix.desktop-arch }}" | tee "$report"
{
echo "### Backend sidecar smoke: ${{ matrix.name }}"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate updater signing secrets
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=0
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then
echo "::error::Missing TAURI_SIGNING_PRIVATE_KEY secret for signed updater artifacts."
missing=1
fi
if [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then
echo "::error::Missing TAURI_SIGNING_PRIVATE_KEY_PASSWORD secret for signed updater artifacts."
missing=1
fi
[ "$missing" -eq 0 ]
- name: Validate macOS signing and notarization secrets
if: matrix.platform == 'mac'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE || secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD || secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
missing=0
if [ -z "$APPLE_CERTIFICATE" ]; then
echo "::error::Missing APPLE_CERTIFICATE or APPLE_CERTIFICATE_BASE64 secret."
missing=1
fi
if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then
echo "::error::Missing APPLE_CERTIFICATE_PASSWORD secret."
missing=1
fi
if [ -z "$APPLE_SIGNING_IDENTITY" ]; then
echo "APPLE_SIGNING_IDENTITY is not configured; the workflow will derive it from the imported certificate."
fi
has_api_key=false
if [ -n "$APPLE_API_KEY_BASE64" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_ISSUER" ]; then
has_api_key=true
fi
has_apple_id=false
if [ -n "$APPLE_ID" ] && [ -n "$APPLE_PASSWORD" ] && [ -n "$APPLE_TEAM_ID" ]; then
has_apple_id=true
fi
if [ "$has_api_key" != "true" ] && [ "$has_apple_id" != "true" ]; then
echo "::error::Missing notarization credentials. Configure APPLE_API_KEY_BASE64 + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_PASSWORD/APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID."
missing=1
fi
[ "$missing" -eq 0 ]
- name: Import Apple Developer certificate
if: matrix.platform == 'mac'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE || secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
certificate_path="$RUNNER_TEMP/apple-certificate.p12"
keychain_password="${KEYCHAIN_PASSWORD:-$(uuidgen)}"
keychain_path="$RUNNER_TEMP/build.keychain-db"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$certificate_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path"
security list-keychain -d user -s "$keychain_path"
security find-identity -v -p codesigning "$keychain_path"
if [ -n "$APPLE_SIGNING_IDENTITY" ]; then
resolved_identity="$APPLE_SIGNING_IDENTITY"
else
resolved_identity="$(security find-identity -v -p codesigning "$keychain_path" | awk -F'"' '/Developer ID Application/ { print $2; exit }')"
fi
if [ -z "$resolved_identity" ]; then
echo "::error::Could not find a Developer ID Application signing identity in the imported certificate."
exit 1
fi
echo "APPLE_SIGNING_IDENTITY=$resolved_identity" >> "$GITHUB_ENV"
echo "Imported signing identity: $resolved_identity"
- name: Prepare Apple API key for notarization
if: matrix.platform == 'mac'
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
if [ -z "$APPLE_API_KEY_BASE64" ] || [ -z "$APPLE_API_KEY_ID" ] || [ -z "$APPLE_API_ISSUER" ]; then
echo "App Store Connect API key secrets are not configured; falling back to Apple ID notarization if available."
exit 0
fi
api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$api_key_path"
chmod 600 "$api_key_path"
echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV"
echo "APPLE_API_KEY=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
echo "Prepared App Store Connect API key for notarization."
- name: Clean previous desktop bundle outputs
shell: bash
run: |
set -euo pipefail
if [ -d apps/desktop/src-tauri/target ]; then
find apps/desktop/src-tauri/target -path '*/release/bundle' -type d -prune -exec rm -rf {} +
fi
- name: Build desktop app and upload release assets
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE || secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: apps/desktop
tagName: ${{ inputs.release_tag || github.ref_name }}
releaseName: CC Branch Desktop ${{ inputs.release_tag || github.ref_name }}
releaseBody: |
# CC Branch Desktop
This desktop build bundles the CC Branch backend sidecar. You do not need
to install Python or the `cc-branch` Python package separately.
## Which file should I download?
| Platform | Recommended download | Notes |
| --- | --- | --- |
| macOS Apple Silicon | `.dmg` asset containing `aarch64` | Signed and notarized DMG for M1/M2/M3/M4 Macs. |
| macOS Intel | `.dmg` asset containing `x64` | Signed and notarized DMG for older Intel Macs. |
| Windows | `.msi` asset containing `x64_en-US` | Standard Windows installer. Use the `.exe` setup file only if you prefer an executable installer. |
| Ubuntu / Debian | `.deb` asset containing `amd64` | Best choice for Debian-based Linux distributions. |
| Fedora / RHEL | `.rpm` asset containing `x86_64` | Best choice for RPM-based Linux distributions. |
| Other Linux | `.AppImage` asset containing `amd64` | Portable Linux build. Larger because it bundles more runtime dependencies. |
## Desktop updates
This release includes signed updater metadata. After installing the desktop
app, open Settings to check for future updates or keep automatic checks on.
## CLI package
CLI users can install the Python package from PyPI with
`python3.10 -m pip install --upgrade cc-branch` or any newer
supported Python. The desktop app already bundles its backend.
releaseDraft: true
prerelease: false
includeUpdaterJson: false
retryAttempts: 2
args: ${{ matrix.args }}
- name: Smoke test packaged macOS desktop app
if: matrix.platform == 'mac'
shell: bash
run: |
set -euo pipefail
app_paths_file="$(mktemp)"
trap 'rm -f "$app_paths_file"' EXIT
find apps/desktop/src-tauri/target -path '*/bundle/macos/CC Branch.app' -type d > "$app_paths_file"
app_count="$(wc -l < "$app_paths_file" | tr -d ' ')"
if [ "$app_count" -eq 0 ]; then
echo "::error::Could not find generated macOS app bundle."
exit 1
fi
if [ "$app_count" -gt 1 ]; then
echo "::error::Multiple generated macOS app bundles found." >&2
cat "$app_paths_file" >&2
exit 1
fi
app_path="$(cat "$app_paths_file")"
mkdir -p smoke-reports
report="smoke-reports/desktop-app-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py "$app_path" \
--timeout "${{ matrix.desktop-smoke-timeout }}" \
--use-auto-port \
--expected-version "${{ inputs.release_tag || github.ref_name }}" \
--expected-platform "${{ matrix.desktop-platform }}" \
--expected-arch "${{ matrix.desktop-arch }}" | tee "$report"
missing_sidecar_dir="$(mktemp -d)"
missing_sidecar_app="$missing_sidecar_dir/CC Branch.app"
cp -R "$app_path" "$missing_sidecar_app"
rm -f "$missing_sidecar_app/Contents/MacOS/cc-branch-backend"
missing_sidecar_executable="$missing_sidecar_app/Contents/MacOS/cc-branch"
chmod +x "$missing_sidecar_executable"
failure_report="smoke-reports/desktop-app-missing-sidecar-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py "$missing_sidecar_executable" \
--expect-startup-failure \
--timeout "${{ matrix.desktop-smoke-timeout }}" | tee "$failure_report"
recovery_report="smoke-reports/desktop-app-recovery-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py "$missing_sidecar_executable" \
--expect-startup-failure-recovery \
--timeout "${{ matrix.desktop-smoke-timeout }}" \
--recovery-sidecar "$app_path/Contents/MacOS/cc-branch-backend" \
--expected-version "${{ inputs.release_tag || github.ref_name }}" \
--expected-platform "${{ matrix.desktop-platform }}" \
--expected-arch "${{ matrix.desktop-arch }}" | tee "$recovery_report"
rm -rf "$missing_sidecar_dir"
stale_report="smoke-reports/desktop-app-stale-backend-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py "$app_path" \
--expect-stale-backend-rejection \
--timeout "${{ matrix.desktop-smoke-timeout }}" | tee "$stale_report"
{
echo "### Packaged desktop smoke: ${{ matrix.name }}"
echo '```json'
cat "$report"
echo '```'
echo "### Missing sidecar failure smoke: ${{ matrix.name }}"
echo '```json'
cat "$failure_report"
echo '```'
echo "### Startup failure recovery smoke: ${{ matrix.name }}"
echo '```json'
cat "$recovery_report"
echo '```'
echo "### Stale backend rejection smoke: ${{ matrix.name }}"
echo '```json'
cat "$stale_report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Smoke test packaged Linux desktop app
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
executable="apps/desktop/src-tauri/target/release/cc-branch"
if [ ! -x "$executable" ]; then
echo "::error::Could not find generated Linux app executable: $executable"
exit 1
fi
mkdir -p smoke-reports
report="smoke-reports/desktop-app-${{ matrix.sidecar-target }}.json"
xvfb-run -a python scripts/smoke-test-desktop-app.py "$executable" \
--timeout "${{ matrix.desktop-smoke-timeout }}" \
--use-auto-port \
--expected-version "${{ inputs.release_tag || github.ref_name }}" \
--expected-platform "${{ matrix.desktop-platform }}" \
--expected-arch "${{ matrix.desktop-arch }}" | tee "$report"
missing_sidecar_dir="$(mktemp -d)"
cp "$executable" "$missing_sidecar_dir/cc-branch"
chmod +x "$missing_sidecar_dir/cc-branch"
failure_report="smoke-reports/desktop-app-missing-sidecar-${{ matrix.sidecar-target }}.json"
xvfb-run -a python scripts/smoke-test-desktop-app.py "$missing_sidecar_dir/cc-branch" \
--expect-startup-failure \
--timeout "${{ matrix.desktop-smoke-timeout }}" | tee "$failure_report"
recovery_report="smoke-reports/desktop-app-recovery-${{ matrix.sidecar-target }}.json"
xvfb-run -a python scripts/smoke-test-desktop-app.py "$missing_sidecar_dir/cc-branch" \
--expect-startup-failure-recovery \
--timeout "${{ matrix.desktop-smoke-timeout }}" \
--recovery-sidecar "$(dirname "$executable")/cc-branch-backend" \
--expected-version "${{ inputs.release_tag || github.ref_name }}" \
--expected-platform "${{ matrix.desktop-platform }}" \
--expected-arch "${{ matrix.desktop-arch }}" | tee "$recovery_report"
rm -rf "$missing_sidecar_dir"
stale_report="smoke-reports/desktop-app-stale-backend-${{ matrix.sidecar-target }}.json"
xvfb-run -a python scripts/smoke-test-desktop-app.py "$executable" \
--expect-stale-backend-rejection \
--timeout "${{ matrix.desktop-smoke-timeout }}" | tee "$stale_report"
{
echo "### Packaged desktop smoke: ${{ matrix.name }}"
echo '```json'
cat "$report"
echo '```'
echo "### Missing sidecar failure smoke: ${{ matrix.name }}"
echo '```json'
cat "$failure_report"
echo '```'
echo "### Startup failure recovery smoke: ${{ matrix.name }}"
echo '```json'
cat "$recovery_report"
echo '```'
echo "### Stale backend rejection smoke: ${{ matrix.name }}"
echo '```json'
cat "$stale_report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Smoke test packaged Windows desktop app
if: matrix.platform == 'windows'
shell: pwsh
run: |
$executable = "apps/desktop/src-tauri/target/release/cc-branch.exe"
if (!(Test-Path $executable)) {
Write-Error "Could not find generated Windows app executable: $executable"
exit 1
}
New-Item -ItemType Directory -Force -Path smoke-reports | Out-Null
$report = "smoke-reports/desktop-app-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py $executable `
--timeout "${{ matrix.desktop-smoke-timeout }}" `
--use-auto-port `
--expected-version "${{ inputs.release_tag || github.ref_name }}" `
--expected-platform "${{ matrix.desktop-platform }}" `
--expected-arch "${{ matrix.desktop-arch }}" | Tee-Object -FilePath $report
$missingSidecarDir = Join-Path $env:RUNNER_TEMP "cc-branch-missing-sidecar"
Remove-Item -Recurse -Force $missingSidecarDir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $missingSidecarDir | Out-Null
$missingExecutable = Join-Path $missingSidecarDir "cc-branch.exe"
Copy-Item $executable $missingExecutable -Force
$failureReport = "smoke-reports/desktop-app-missing-sidecar-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py $missingExecutable `
--expect-startup-failure `
--timeout "${{ matrix.desktop-smoke-timeout }}" | Tee-Object -FilePath $failureReport
$recoveryReport = "smoke-reports/desktop-app-recovery-${{ matrix.sidecar-target }}.json"
$recoverySidecar = "apps/desktop/src-tauri/target/release/cc-branch-backend.exe"
python scripts/smoke-test-desktop-app.py $missingExecutable `
--expect-startup-failure-recovery `
--timeout "${{ matrix.desktop-smoke-timeout }}" `
--recovery-sidecar $recoverySidecar `
--expected-version "${{ inputs.release_tag || github.ref_name }}" `
--expected-platform "${{ matrix.desktop-platform }}" `
--expected-arch "${{ matrix.desktop-arch }}" | Tee-Object -FilePath $recoveryReport
$staleReport = "smoke-reports/desktop-app-stale-backend-${{ matrix.sidecar-target }}.json"
python scripts/smoke-test-desktop-app.py $executable `
--expect-stale-backend-rejection `
--timeout "${{ matrix.desktop-smoke-timeout }}" | Tee-Object -FilePath $staleReport
"### Packaged desktop smoke: ${{ matrix.name }}" >> $env:GITHUB_STEP_SUMMARY
'```json' >> $env:GITHUB_STEP_SUMMARY
Get-Content $report >> $env:GITHUB_STEP_SUMMARY
'```' >> $env:GITHUB_STEP_SUMMARY
"### Missing sidecar failure smoke: ${{ matrix.name }}" >> $env:GITHUB_STEP_SUMMARY
'```json' >> $env:GITHUB_STEP_SUMMARY
Get-Content $failureReport >> $env:GITHUB_STEP_SUMMARY
'```' >> $env:GITHUB_STEP_SUMMARY
"### Startup failure recovery smoke: ${{ matrix.name }}" >> $env:GITHUB_STEP_SUMMARY
'```json' >> $env:GITHUB_STEP_SUMMARY
Get-Content $recoveryReport >> $env:GITHUB_STEP_SUMMARY
'```' >> $env:GITHUB_STEP_SUMMARY
"### Stale backend rejection smoke: ${{ matrix.name }}" >> $env:GITHUB_STEP_SUMMARY
'```json' >> $env:GITHUB_STEP_SUMMARY
Get-Content $staleReport >> $env:GITHUB_STEP_SUMMARY
'```' >> $env:GITHUB_STEP_SUMMARY
- name: Verify macOS DMG contains backend sidecar
if: matrix.platform == 'mac'
shell: bash
env:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
dmg_paths_file="$(mktemp)"
trap 'rm -f "$dmg_paths_file"' EXIT
find apps/desktop/src-tauri/target -path '*/bundle/dmg/*.dmg' -type f > "$dmg_paths_file"
dmg_count="$(wc -l < "$dmg_paths_file" | tr -d ' ')"
if [ "$dmg_count" -eq 0 ]; then
echo "::error::Could not find generated macOS DMG."
exit 1
fi
if [ "$dmg_count" -gt 1 ]; then
echo "::error::Multiple generated macOS DMGs found." >&2
cat "$dmg_paths_file" >&2
exit 1
fi
dmg_path="$(cat "$dmg_paths_file")"
mkdir -p smoke-reports
report="smoke-reports/dmg-verification-${{ matrix.sidecar-target }}.json"
python scripts/verify-macos-dmg.py "$dmg_path" --launch-app --verify-installed-copy --verify-stale-backend-rejection --expected-version "$version" --launch-timeout "${{ matrix.desktop-smoke-timeout }}" | tee "$report"
{
echo "### macOS DMG verification: ${{ matrix.name }}"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Verify Linux installers contain backend sidecar
if: matrix.platform == 'linux'
shell: bash
env:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
mkdir -p smoke-reports
report="smoke-reports/linux-installers-${{ matrix.sidecar-target }}.json"
xvfb-run -a python scripts/verify-desktop-installers.py \
--bundle-dir apps/desktop/src-tauri/target/release/bundle \
--platform linux \
--expected-version "$version" \
--launch-timeout "${{ matrix.desktop-smoke-timeout }}" \
--launch-linux-packages --launch-appimage | tee "$report"
{
echo "### Linux installer verification: ${{ matrix.name }}"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Verify Windows installers contain backend sidecar
if: matrix.platform == 'windows'
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
$version = $env:RELEASE_TAG.TrimStart("v")
New-Item -ItemType Directory -Force -Path smoke-reports | Out-Null
$report = "smoke-reports/windows-installers-${{ matrix.sidecar-target }}.json"
python scripts/verify-desktop-installers.py `
--bundle-dir apps/desktop/src-tauri/target/release/bundle `
--platform windows `
--expected-version $version `
--launch-timeout "${{ matrix.desktop-smoke-timeout }}" `
--launch-windows-msi `
--launch-windows-nsis | Tee-Object -FilePath $report
"### Windows installer verification: ${{ matrix.name }}" >> $env:GITHUB_STEP_SUMMARY
'```json' >> $env:GITHUB_STEP_SUMMARY
Get-Content $report >> $env:GITHUB_STEP_SUMMARY
'```' >> $env:GITHUB_STEP_SUMMARY
- name: Upload desktop smoke reports
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-smoke-reports-${{ matrix.platform }}-${{ matrix.sidecar-target }}
path: smoke-reports/*.json
if-no-files-found: error
- name: Notarize and replace macOS DMG asset
if: matrix.platform == 'mac'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD || secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
dmg_paths_file="$(mktemp)"
trap 'rm -f "$dmg_paths_file"' EXIT
find apps/desktop/src-tauri/target -path '*/bundle/dmg/*.dmg' -type f > "$dmg_paths_file"
dmg_count="$(wc -l < "$dmg_paths_file" | tr -d ' ')"
if [ "$dmg_count" -eq 0 ]; then
echo "::error::Could not find generated macOS DMG."
exit 1
fi
if [ "$dmg_count" -gt 1 ]; then
echo "::error::Multiple generated macOS DMGs found." >&2
cat "$dmg_paths_file" >&2
exit 1
fi
dmg_path="$(cat "$dmg_paths_file")"
codesign --force --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$dmg_path"
if [ -n "${APPLE_API_KEY_PATH:-}" ] && [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; then
xcrun notarytool submit "$dmg_path" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
else
xcrun notarytool submit "$dmg_path" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
fi
xcrun stapler staple "$dmg_path"
xcrun stapler validate "$dmg_path"
spctl -a -vv -t open --context context:primary-signature "$dmg_path"
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber
publish-updater-json:
name: Publish updater metadata
runs-on: ubuntu-22.04
needs: build
if: ${{ needs.build.result == 'success' }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Publish complete latest.json
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
assets_json="$tmp/assets.json"
release_notes="$tmp/release-notes.md"
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "*.sig" --dir "$tmp"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > "$assets_json"
assets="$(jq -r '.assets[].name' "$assets_json")"
pub_date="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
pick_asset() {
local pattern="$1"
local matches
local match_count
matches="$(printf '%s\n' "$assets" | grep -E "$pattern" || true)"
match_count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')"
if [ "$match_count" -eq 0 ]; then
echo "::error::Missing release asset matching pattern: $pattern" >&2
printf '%s\n' "$assets" >&2
exit 1
fi
if [ "$match_count" -gt 1 ]; then
echo "::error::Multiple release assets matching pattern: $pattern" >&2
printf '%s\n' "$matches" >&2
exit 1
fi
printf '%s' "$matches"
}
sig() {
local file="$1.sig"
if [ ! -f "$tmp/$file" ]; then
echo "::error::Missing updater signature asset: $file" >&2
exit 1
fi
tr -d '\n' < "$tmp/$file"
}
url_path() {
python -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$1"
}
url() {
printf 'https://github.com/%s/releases/download/%s/%s' "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$(url_path "$1")"
}
mac_arm="$(pick_asset 'aarch64\.app\.tar\.gz$')"
mac_intel="$(pick_asset 'x64\.app\.tar\.gz$')"
mac_arm_dmg="$(pick_asset 'aarch64.*\.dmg$')"
mac_intel_dmg="$(pick_asset 'x64.*\.dmg$')"
win_msi="$(pick_asset 'x64_en-US\.msi$')"
win_nsis="$(pick_asset 'x64-setup\.exe$')"
linux_appimage="$(pick_asset 'amd64\.AppImage$')"
linux_deb="$(pick_asset 'amd64\.deb$')"
linux_rpm="$(pick_asset 'x86_64\.rpm$')"
checksum_dir="$tmp/checksums"
mkdir -p "$checksum_dir"
checksum_assets=(
"$mac_arm"
"$mac_intel"
"$mac_arm_dmg"
"$mac_intel_dmg"
"$win_msi"
"$win_nsis"
"$linux_appimage"
"$linux_deb"
"$linux_rpm"
)
for asset in "${checksum_assets[@]}"; do
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$asset" --dir "$checksum_dir"
done
(
cd "$checksum_dir"
for asset in "${checksum_assets[@]}"; do
sha256sum -- "$asset"
done
) > "$tmp/SHA256SUMS"
gh release upload "$RELEASE_TAG" "$tmp/SHA256SUMS" --repo "$GITHUB_REPOSITORY" --clobber
python scripts/render_desktop_release_notes.py \
--tag "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--assets-json "$assets_json" \
--output "$release_notes"
notes="$(cat "$release_notes")"
jq -n \
--arg version "$version" \
--arg notes "$notes" \
--arg pub_date "$pub_date" \
--arg mac_arm_url "$(url "$mac_arm")" \
--arg mac_arm_sig "$(sig "$mac_arm")" \
--arg mac_intel_url "$(url "$mac_intel")" \
--arg mac_intel_sig "$(sig "$mac_intel")" \
--arg win_msi_url "$(url "$win_msi")" \
--arg win_msi_sig "$(sig "$win_msi")" \
--arg win_nsis_url "$(url "$win_nsis")" \
--arg win_nsis_sig "$(sig "$win_nsis")" \
--arg linux_appimage_url "$(url "$linux_appimage")" \
--arg linux_appimage_sig "$(sig "$linux_appimage")" \
--arg linux_deb_url "$(url "$linux_deb")" \
--arg linux_deb_sig "$(sig "$linux_deb")" \
--arg linux_rpm_url "$(url "$linux_rpm")" \
--arg linux_rpm_sig "$(sig "$linux_rpm")" \
'{
version: $version,
notes: $notes,
pub_date: $pub_date,
platforms: {
"darwin-aarch64": { signature: $mac_arm_sig, url: $mac_arm_url },
"darwin-x86_64": { signature: $mac_intel_sig, url: $mac_intel_url },
"windows-x86_64": { signature: $win_msi_sig, url: $win_msi_url },
"windows-x86_64-msi": { signature: $win_msi_sig, url: $win_msi_url },
"windows-x86_64-nsis": { signature: $win_nsis_sig, url: $win_nsis_url },
"linux-x86_64": { signature: $linux_appimage_sig, url: $linux_appimage_url },
"linux-x86_64-appimage": { signature: $linux_appimage_sig, url: $linux_appimage_url },
"linux-x86_64-deb": { signature: $linux_deb_sig, url: $linux_deb_url },
"linux-x86_64-rpm": { signature: $linux_rpm_sig, url: $linux_rpm_url }
}
}' > "$tmp/latest.json"
gh release upload "$RELEASE_TAG" "$tmp/latest.json" --repo "$GITHUB_REPOSITORY" --clobber
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --notes-file "$release_notes"
canary-release:
name: Canary release assets
runs-on: ubuntu-22.04
needs: publish-updater-json
if: ${{ needs.publish-updater-json.result == 'success' }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Download canary assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
version="${RELEASE_TAG#v}"
assets_file="$tmp/assets.txt"
assets_json="$tmp/assets.json"
release_body="$tmp/release-body.md"
mkdir -p release-verification
report="release-verification/release-canary-verification.json"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > "$assets_json"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json body -q .body > "$release_body"
jq -r '.assets[].name' "$assets_json" > "$assets_file"
sample_assets="$(grep -E 'aarch64\.app\.tar\.gz$' "$assets_file" || true)"
sample_asset_count="$(printf '%s\n' "$sample_assets" | sed '/^$/d' | wc -l | tr -d ' ')"
macos_updater_assets="$(
grep -E '\.app\.tar\.gz$' "$assets_file" || true
)"
if [ "$sample_asset_count" -eq 0 ]; then
echo "::error::Could not find macOS Apple Silicon updater asset."
exit 1
fi
if [ "$sample_asset_count" -gt 1 ]; then
echo "::error::Multiple macOS Apple Silicon updater assets found." >&2
printf '%s\n' "$sample_assets" >&2
exit 1
fi
sample_asset="$sample_assets"
if [ -z "$macos_updater_assets" ]; then
echo "::error::Could not find any macOS updater app tarball assets."
exit 1
fi
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "latest.json" --dir "$tmp"
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "SHA256SUMS" --dir "$tmp"
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "*.sig" --dir "$tmp"
while IFS= read -r updater_asset; do
if [ -z "$updater_asset" ]; then
continue
fi
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$updater_asset" --dir "$tmp"
done <<< "$macos_updater_assets"
python scripts/verify_release_canary.py \
--release-dir "$tmp" \
--sample-asset "$sample_asset" \
--expected-version "$version" \
--assets-json "$assets_json" \
--release-body-file "$release_body" \
--repo "$GITHUB_REPOSITORY" \
--tag "$RELEASE_TAG" | tee "$report"
{
echo "### Release canary verification"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release canary verification report
if: always()
uses: actions/upload-artifact@v7
with:
name: release-canary-verification
path: release-verification/release-canary-verification.json
if-no-files-found: error
canary-installers:
name: Canary installer downloads (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
needs: publish-updater-json
if: ${{ needs.publish-updater-json.result == 'success' }}
strategy:
fail-fast: false
matrix:
include:
- platform: mac-apple-silicon
os: macos-14
verifier-args: --verify-dmg --launch-dmg-app --verify-dmg-installed-copy --verify-macos-gatekeeper --sample-platform darwin-aarch64
- platform: mac-intel
os: macos-15-intel
verifier-args: --verify-dmg --launch-dmg-app --verify-dmg-installed-copy --verify-macos-gatekeeper --sample-platform darwin-x86_64
- platform: linux
os: ubuntu-22.04
verifier-args: --skip-dmg --verify-linux-installers --launch-linux-packages --launch-linux-appimage
- platform: windows
os: windows-latest
verifier-args: --skip-dmg --verify-windows-installer --launch-windows-msi --launch-windows-nsis
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Install Linux verifier tools
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev rpm cpio xvfb
- name: Download and verify draft installer assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
mkdir -p release-verification
report="release-verification/github-release-verification-draft-${{ matrix.platform }}.json"
if [ "${{ matrix.platform }}" = "linux" ]; then
xvfb-run -a python scripts/verify_github_release.py \
"$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--expected-version "$version" \
--launch-timeout 90 \
${{ matrix.verifier-args }} | tee "$report"
else
python scripts/verify_github_release.py \
"$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--expected-version "$version" \
--launch-timeout 90 \
${{ matrix.verifier-args }} | tee "$report"
fi
{
echo "### Draft installer verification: ${{ matrix.platform }}"
echo "#### Verification summary"
python - "$report" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
report = json.load(handle)
if not isinstance(report.get("verification_summary"), dict):
raise SystemExit("verification_summary missing from release verification report")
checks = report['verification_summary'].get('checks')
if not isinstance(checks, dict):
raise SystemExit("verification_summary checks missing from release verification report")
failed_checks = sorted(name for name, ok in checks.items() if ok is not True)
if failed_checks:
raise SystemExit("verification_summary checks failed: " + ", ".join(failed_checks))
if report['verification_summary'].get('ready_for_public_download') is not True:
raise SystemExit("verification_summary ready_for_public_download is not true")
print("```json")
print(json.dumps(report['verification_summary'], indent=2, sort_keys=True))
print("```")
PY
echo "#### Full report"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload draft installer verification report
if: always()
uses: actions/upload-artifact@v7
with:
name: draft-installer-verification-${{ matrix.platform }}
path: release-verification/*.json
if-no-files-found: error
publish-release:
name: Publish release
runs-on: ubuntu-22.04
needs:
- canary-release
- canary-installers
if: ${{ needs.canary-release.result == 'success' && needs.canary-installers.result == 'success' }}
steps:
- name: Publish draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=false \
--prerelease=false \
--latest
verify-live-release:
name: Verify live GitHub downloads (${{ matrix.platform }})
runs-on: ${{ matrix.os }}
needs: publish-release
if: ${{ needs.publish-release.result == 'success' }}
strategy:
fail-fast: false
matrix:
include:
- platform: mac-apple-silicon
os: macos-14
verifier-args: --verify-dmg --launch-dmg-app --verify-dmg-installed-copy --verify-macos-gatekeeper --sample-platform darwin-aarch64
- platform: mac-intel
os: macos-15-intel
verifier-args: --verify-dmg --launch-dmg-app --verify-dmg-installed-copy --verify-macos-gatekeeper --sample-platform darwin-x86_64
- platform: linux
os: ubuntu-22.04
verifier-args: --skip-dmg --verify-linux-installers --launch-linux-packages --launch-linux-appimage
- platform: windows
os: windows-latest
verifier-args: --skip-dmg --verify-windows-installer --launch-windows-msi --launch-windows-nsis
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.release_tag || github.ref }}
- name: Install Linux verifier tools
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev rpm cpio xvfb
- name: Download and verify published assets
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
mkdir -p release-verification
report="release-verification/github-release-verification-live-${{ matrix.platform }}.json"
if [ "${{ matrix.platform }}" = "linux" ]; then
xvfb-run -a python scripts/verify_github_release.py \
"$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--expected-version "$version" \
--require-latest \
--require-public \
--launch-timeout 90 \
${{ matrix.verifier-args }} | tee "$report"
else
python scripts/verify_github_release.py \
"$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--expected-version "$version" \
--require-latest \
--require-public \
--launch-timeout 90 \
${{ matrix.verifier-args }} | tee "$report"
fi
{
echo "### Live installer verification: ${{ matrix.platform }}"
echo "#### Verification summary"
python - "$report" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
report = json.load(handle)
if not isinstance(report.get("verification_summary"), dict):
raise SystemExit("verification_summary missing from release verification report")
checks = report['verification_summary'].get('checks')
if not isinstance(checks, dict):
raise SystemExit("verification_summary checks missing from release verification report")
failed_checks = sorted(name for name, ok in checks.items() if ok is not True)
if failed_checks:
raise SystemExit("verification_summary checks failed: " + ", ".join(failed_checks))
if report['verification_summary'].get('ready_for_public_download') is not True:
raise SystemExit("verification_summary ready_for_public_download is not true")
print("```json")
print(json.dumps(report['verification_summary'], indent=2, sort_keys=True))
print("```")
PY
echo "#### Full report"
echo '```json'
cat "$report"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload live installer verification report
if: always()
uses: actions/upload-artifact@v7
with:
name: live-installer-verification-${{ matrix.platform }}
path: release-verification/*.json
if-no-files-found: error
rollback-live-release:
name: Roll back failed live release
runs-on: ubuntu-22.04
needs:
- publish-release
- verify-live-release
if: ${{ always() && needs.publish-release.result == 'success' && needs.verify-live-release.result != 'success' }}
steps:
- name: Rollback failed live release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
set -euo pipefail
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft=true \
--prerelease=true
{
echo "### Live release rolled back"
echo "Live GitHub download verification did not pass, so $RELEASE_TAG was returned to draft/prerelease state."
} >> "$GITHUB_STEP_SUMMARY"