ci: migrate ESLint to Biome, optimize workflows, fix tar vulnerability (#1289)

* ci: migrate ESLint to Biome, optimize workflows, fix tar vulnerability

- Replace ESLint with Biome (15-25x faster linting)
- Pin Biome to 2.3.11 for consistent behavior across local/CI
- Disable useArrowFunction rule (breaks vitest constructor mocks)
- Add composite actions for DRY workflow setup
- Fix tar vulnerability (CVE-2026-23745) by upgrading to v7.5.3
- Add @electron/rebuild override to ensure consistent tar version
- Update electron-builder to 26.4.0

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

* fix(workflows): address all 15 PR review findings

HIGH priority fixes:
- Add tar@7.5.3 override to frontend package.json (CVE-2026-23745)
- Use setup-node-frontend composite action in release.yml (4 build jobs)
- Use setup-node-frontend composite action in beta-release.yml (4 build jobs)

MEDIUM priority fixes:
- Add notarization status verification ('Accepted') before stapling
- Add blockmap files to beta-release asset copying (delta updates)
- Add DMG validation with fallback in release.yml
- Extract yq checksum to env block, single definition per step
- Fix snake_case to kebab-case in notarization action outputs

LOW priority fixes:
- Add config files (pyproject.toml, tsconfig*.json, biome.jsonc) to CI paths
- Document yq checksum requirement in merge-macos-manifests
- Always use jq for notarization ID parsing (no regex fallback)
- Add blockmap files to dry-run-summary job
- Change noControlCharactersInRegex from off to warn
- Rename biome.json to biome.jsonc, add comments explaining disabled rules

noSecrets rule kept off due to 2700+ false positives on normal strings.

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

* fix(lint): correct biome.jsonc path in workflow triggers

The lint workflow path filter referenced 'biome.json' but the actual
config file is 'biome.jsonc' (renamed to support comments). This fix
ensures the lint workflow triggers when the Biome config is modified.

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

* fix(workflows): address 6 PR review findings

- QUAL-001/002: Add DMG file existence checks before stapling
- QUAL-003: Quote all path variables in merge-macos-manifests
- QUAL-004: Add semver validation in update-readme.py
- QUAL-005: Document noDangerouslySetInnerHtml security rule decision
- LOGIC-001: Add warning when both notarization IDs are empty

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

* feat(workflows): add gate jobs for branch protection

Add summary/gate jobs to match existing branch protection rules:
- CI Complete: aggregates test-python and test-frontend results
- Lint Complete: aggregates python and typescript lint results
- Security Summary: aggregates codeql and python-security results

These jobs provide a single status check for branch protection instead
of requiring individual job names which can change with matrix configs.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-18 14:29:50 +01:00
committed by GitHub
parent 4b74092879
commit 0b2cf9b06c
29 changed files with 1347 additions and 3463 deletions
@@ -0,0 +1,160 @@
name: 'Finalize macOS Notarization'
description: 'Wait for Apple notarization to complete and staple tickets to DMG files'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
intel-notarization-id:
description: 'Notarization request ID for Intel build'
required: false
default: ''
arm64-notarization-id:
description: 'Notarization request ID for ARM64 build'
required: false
default: ''
intel-dmg-file:
description: 'Filename of the Intel DMG'
required: false
default: ''
arm64-dmg-file:
description: 'Filename of the ARM64 DMG'
required: false
default: ''
intel-artifact-path:
description: 'Path to Intel build artifacts'
required: false
default: 'intel'
arm64-artifact-path:
description: 'Path to ARM64 build artifacts'
required: false
default: 'arm64'
timeout:
description: 'Timeout in seconds for notarization wait'
required: false
default: '3600'
outputs:
intel-stapled:
description: 'Whether Intel DMG was successfully stapled'
value: ${{ steps.staple.outputs.intel_stapled }}
arm64-stapled:
description: 'Whether ARM64 DMG was successfully stapled'
value: ${{ steps.staple.outputs.arm64_stapled }}
runs:
using: 'composite'
steps:
- name: Wait for notarization and staple
id: staple
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
INTEL_NOTARIZATION_ID: ${{ inputs.intel-notarization-id }}
ARM64_NOTARIZATION_ID: ${{ inputs.arm64-notarization-id }}
INTEL_DMG: ${{ inputs.intel-dmg-file }}
ARM64_DMG: ${{ inputs.arm64-dmg-file }}
INTEL_PATH: ${{ inputs.intel-artifact-path }}
ARM64_PATH: ${{ inputs.arm64-artifact-path }}
TIMEOUT: ${{ inputs.timeout }}
run: |
intel_stapled=false
arm64_stapled=false
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization wait: APPLE_ID not configured"
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Warn if no notarization IDs provided (could indicate submission failure)
if [ -z "$INTEL_NOTARIZATION_ID" ] && [ -z "$ARM64_NOTARIZATION_ID" ]; then
echo "::warning::No notarization IDs provided - nothing to finalize. Check if notarization submission succeeded."
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Wait for Intel notarization
if [ -n "$INTEL_NOTARIZATION_ID" ]; then
echo "Waiting for Intel notarization: $INTEL_NOTARIZATION_ID"
if ! xcrun notarytool wait "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::Intel notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
INTEL_STATUS=$(xcrun notarytool info "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$INTEL_STATUS" != "Accepted" ]; then
echo "::error::Intel notarization status is '$INTEL_STATUS', expected 'Accepted'"
exit 1
fi
echo "Intel notarization status: $INTEL_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$INTEL_PATH/$INTEL_DMG" ]; then
echo "::error::Intel DMG not found at $INTEL_PATH/$INTEL_DMG"
exit 1
fi
echo "Stapling Intel DMG: $INTEL_PATH/$INTEL_DMG"
if ! xcrun stapler staple "$INTEL_PATH/$INTEL_DMG"; then
echo "::error::Failed to staple Intel DMG"
exit 1
fi
echo "Successfully stapled Intel DMG"
intel_stapled=true
fi
# Wait for ARM64 notarization
if [ -n "$ARM64_NOTARIZATION_ID" ]; then
echo "Waiting for ARM64 notarization: $ARM64_NOTARIZATION_ID"
if ! xcrun notarytool wait "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::ARM64 notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
ARM64_STATUS=$(xcrun notarytool info "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$ARM64_STATUS" != "Accepted" ]; then
echo "::error::ARM64 notarization status is '$ARM64_STATUS', expected 'Accepted'"
exit 1
fi
echo "ARM64 notarization status: $ARM64_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$ARM64_PATH/$ARM64_DMG" ]; then
echo "::error::ARM64 DMG not found at $ARM64_PATH/$ARM64_DMG"
exit 1
fi
echo "Stapling ARM64 DMG: $ARM64_PATH/$ARM64_DMG"
if ! xcrun stapler staple "$ARM64_PATH/$ARM64_DMG"; then
echo "::error::Failed to staple ARM64 DMG"
exit 1
fi
echo "Successfully stapled ARM64 DMG"
arm64_stapled=true
fi
echo "intel_stapled=$intel_stapled" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=$arm64_stapled" >> "$GITHUB_OUTPUT"
@@ -33,28 +33,49 @@ runs:
- name: Merge macOS manifests
id: merge
shell: bash
env:
# yq SHA256 checksum for v4.44.3 linux_amd64
# When updating yq-version, update this checksum and the one in validate step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
echo "=== Merging macOS update manifests ==="
# Find all latest-mac.yml files from different build artifacts
intel_manifest=$(find ${{ inputs.dist-path }} -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
arm64_manifest=$(find ${{ inputs.dist-path }} -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
intel_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
arm64_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
echo "Intel manifest: ${intel_manifest:-not found}"
echo "ARM64 manifest: ${arm64_manifest:-not found}"
mkdir -p ${{ inputs.output-path }}
mkdir -p "${{ inputs.output-path }}"
if [ -n "$intel_manifest" ] && [ -n "$arm64_manifest" ]; then
echo "Both architectures found - merging manifests..."
echo "merged=true" >> "$GITHUB_OUTPUT"
# Install yq for YAML merging (pinned version)
# Install yq for YAML merging (pinned version with checksum verification)
YQ_VERSION="${{ inputs.yq-version }}"
if ! sudo wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"; then
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
if ! wget -qO /tmp/yq "$YQ_URL"; then
echo "::error::Failed to download yq ${YQ_VERSION}"
exit 1
fi
# Verify checksum
echo "Verifying yq checksum..."
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
rm -f /tmp/yq
exit 1
fi
echo "Checksum verified successfully"
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
echo "Installed yq version:"
yq --version
@@ -63,19 +84,19 @@ runs:
# This avoids shell expansion issues with multiline YAML
yq eval-all '
select(fileIndex == 0) * {"files": ([.[].files] | add)}
' "$intel_manifest" "$arm64_manifest" > ${{ inputs.output-path }}/latest-mac.yml
' "$intel_manifest" "$arm64_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
echo "Merged manifest contents:"
cat ${{ inputs.output-path }}/latest-mac.yml
cat "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$intel_manifest" ]; then
echo "Only Intel manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$intel_manifest" ${{ inputs.output-path }}/latest-mac.yml
cp "$intel_manifest" "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$arm64_manifest" ]; then
echo "Only ARM64 manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$arm64_manifest" ${{ inputs.output-path }}/latest-mac.yml
cp "$arm64_manifest" "${{ inputs.output-path }}/latest-mac.yml"
else
echo "::error::No macOS manifests found - this will cause auto-update to fail"
exit 1
@@ -84,6 +105,9 @@ runs:
- name: Validate merged manifest
id: validate
shell: bash
env:
# Single source of truth for yq checksum - must match merge step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
manifest_file="${{ inputs.output-path }}/latest-mac.yml"
@@ -98,7 +122,21 @@ runs:
# Install yq if not already installed (for single-arch case)
if ! command -v yq &> /dev/null; then
YQ_VERSION="${{ inputs.yq-version }}"
sudo wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
wget -qO /tmp/yq "$YQ_URL"
# Verify checksum (YQ_SHA256 from env)
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
exit 1
fi
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
fi
@@ -143,13 +181,13 @@ runs:
# Copy other manifests (Windows, Linux) - these don't have the duplicate issue
for manifest in latest.yml latest-linux.yml latest-linux-arm64.yml; do
found=$(find ${{ inputs.dist-path }} -name "$manifest" -type f 2>/dev/null | head -1)
found=$(find "${{ inputs.dist-path }}" -name "$manifest" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
echo "Copying $manifest"
cp "$found" ${{ inputs.output-path }}/
cp "$found" "${{ inputs.output-path }}/"
fi
done
echo ""
echo "=== Manifest files in ${{ inputs.output-path }} ==="
ls -la ${{ inputs.output-path }}/*.yml 2>/dev/null || echo "No manifest files found"
ls -la "${{ inputs.output-path }}"/*.yml 2>/dev/null || echo "No manifest files found"
@@ -0,0 +1,48 @@
name: 'Setup Node.js Frontend'
description: 'Set up Node.js with npm and cached dependencies for the frontend'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '24'
ignore-scripts:
description: 'Whether to use --ignore-scripts flag during npm ci'
required: false
default: 'false'
outputs:
cache-hit:
description: 'Whether npm cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Node.js ${{ inputs.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json', 'apps/frontend/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
working-directory: apps/frontend
shell: bash
run: |
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
npm ci --ignore-scripts
else
npm ci
fi
@@ -0,0 +1,52 @@
name: 'Setup Python Backend'
description: 'Set up Python with uv package manager and cached dependencies for the backend'
inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12'
install-test-deps:
description: 'Whether to install test dependencies'
required: false
default: 'false'
outputs:
cache-hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install uv package manager
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Cache uv dependencies
id: cache
uses: actions/cache@v4
with:
path: |
~/.cache/uv
~/AppData/Local/uv/cache
~/Library/Caches/uv
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles('apps/backend/requirements.txt', 'tests/requirements-test.txt') }}
restore-keys: |
uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
- name: Install dependencies
working-directory: apps/backend
shell: bash
run: |
uv venv
uv pip install -r requirements.txt
if [ "${{ inputs.install-test-deps }}" == "true" ]; then
uv pip install -r ../../tests/requirements-test.txt
fi
@@ -0,0 +1,87 @@
name: 'Submit macOS Notarization'
description: 'Submit a macOS DMG file for Apple notarization asynchronously'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
dmg-path:
description: 'Path to the dist directory containing the DMG file'
required: false
default: 'apps/frontend/dist'
outputs:
notarization-id:
description: 'The notarization request ID'
value: ${{ steps.submit.outputs.notarization_id }}
dmg-file:
description: 'The DMG filename that was submitted'
value: ${{ steps.submit.outputs.dmg_file }}
runs:
using: 'composite'
steps:
- name: Submit notarization (async)
id: submit
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
DMG_PATH: ${{ inputs.dmg-path }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
echo "notarization_id=" >> "$GITHUB_OUTPUT"
echo "dmg_file=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the DMG file
DMG_FILE=$(find "$DMG_PATH" -name "*.dmg" -type f | head -1)
if [ -z "$DMG_FILE" ]; then
echo "::error::No DMG file found in $DMG_PATH"
exit 1
fi
echo "Submitting $DMG_FILE for notarization (async)..."
# Submit for notarization without waiting
# Capture both stdout and exit code
set +e
RESULT=$(xcrun notarytool submit "$DMG_FILE" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--no-wait \
--output-format json 2>&1)
SUBMIT_EXIT_CODE=$?
set -e
echo "$RESULT"
# Check if submission command itself failed (not just missing ID)
if [ $SUBMIT_EXIT_CODE -ne 0 ]; then
echo "::error::notarytool submit failed with exit code $SUBMIT_EXIT_CODE"
exit 1
fi
# Extract the notarization ID from JSON response
# jq is always available on macOS runners
NOTARIZATION_ID=$(echo "$RESULT" | jq -r '.id // empty' 2>/dev/null)
if [ -z "$NOTARIZATION_ID" ]; then
echo "::error::Failed to get notarization ID from response"
echo "Response was: $RESULT"
exit 1
fi
echo "Notarization submitted with ID: $NOTARIZATION_ID"
echo "notarization_id=$NOTARIZATION_ID" >> "$GITHUB_OUTPUT"
echo "dmg_file=$(basename "$DMG_FILE")" >> "$GITHUB_OUTPUT"
+95 -116
View File
@@ -65,6 +65,9 @@ jobs:
build-macos-intel:
needs: create-tag
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
@@ -76,23 +79,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
@@ -132,27 +120,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- 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: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -167,6 +141,9 @@ jobs:
build-macos-arm64:
needs: create-tag
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
@@ -178,23 +155,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -231,27 +193,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- 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: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -282,24 +230,8 @@ jobs:
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: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -470,23 +402,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak
run: |
@@ -540,8 +457,50 @@ jobs:
apps/frontend/dist/*.flatpak
apps/frontend/dist/*.yml
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v4
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v4
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
create-release:
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [create-tag, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run != 'true' }}
permissions:
@@ -561,8 +520,20 @@ jobs:
run: |
mkdir -p release-assets
# Copy binary artifacts (these have unique names per architecture)
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# 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)
@@ -683,7 +654,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dry-run-summary:
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [create-tag, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run == 'true' }}
steps:
@@ -697,7 +668,15 @@ jobs:
- name: Flatten binary 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" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
# Copy stapled macOS DMGs (from finalize-notarization job)
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Merge macOS manifests (same logic as real release)
- name: Merge macOS manifests
+50 -105
View File
@@ -3,16 +3,36 @@
# Tests on all target platforms (Linux, Windows, macOS) to catch
# platform-specific bugs before they merge. ALL platforms must pass.
#
# Why this matters: Platform-specific code often breaks when developers
# commit from one OS without testing on others. This CI prevents that.
# Optimized: Reduced matrix (4 jobs vs 6), merged integration tests,
# coverage on Linux only, path filters to skip on docs-only changes.
name: CI
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- 'pyproject.toml'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- 'pyproject.toml'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
@@ -24,41 +44,38 @@ permissions:
jobs:
# --------------------------------------------------------------------------
# Python Backend Tests - All Platforms
# Python Backend Tests - Optimized Matrix (4 jobs instead of 6)
# --------------------------------------------------------------------------
test-python:
name: test-python (${{ matrix.python-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel all jobs if one platform fails
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.12', '3.13']
# 3.12 on all OS for cross-platform coverage
# 3.13 on Linux only for compatibility check (saves 2 jobs)
include:
- os: ubuntu-latest
python-version: '3.12'
- os: ubuntu-latest
python-version: '3.13'
- os: windows-latest
python-version: '3.12'
- os: macos-latest
python-version: '3.12'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Setup Python backend
uses: ./.github/actions/setup-python-backend
with:
python-version: ${{ matrix.python-version }}
install-test-deps: 'true'
- name: Install uv package manager
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
shell: bash
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
- name: Run all tests (including platform-specific)
working-directory: apps/backend
shell: bash
env:
@@ -71,22 +88,18 @@ jobs:
fi
pytest ../../tests/ -v --tb=short -x
- name: Run coverage (Python 3.12 only)
if: matrix.python-version == '3.12'
- name: Run coverage (Linux + Python 3.12 only)
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
working-directory: apps/backend
shell: bash
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
source .venv/Scripts/activate
else
source .venv/bin/activate
fi
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=10
- name: Upload coverage to Codecov
if: matrix.python-version == '3.12'
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
file: ./apps/backend/coverage.xml
@@ -110,26 +123,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Node.js frontend
uses: ./.github/actions/setup-node-frontend
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
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
working-directory: apps/frontend
run: npm ci --ignore-scripts
ignore-scripts: 'true'
- name: Run TypeScript type check
working-directory: apps/frontend
@@ -143,76 +140,24 @@ jobs:
working-directory: apps/frontend
run: npm run build
# --------------------------------------------------------------------------
# Platform-Specific Integration Tests
# --------------------------------------------------------------------------
test-platform-integration:
name: test-integration (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Only run integration tests after basic tests pass
needs: [test-python, test-frontend]
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install backend dependencies
working-directory: apps/backend
shell: bash
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run platform-specific tests
working-directory: apps/backend
shell: bash
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
source .venv/Scripts/activate
else
source .venv/bin/activate
fi
pytest ../../tests/test_platform.py -v --tb=short
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
ci-complete:
name: CI Complete
runs-on: ubuntu-latest
needs: [test-python, test-frontend, test-platform-integration]
needs: [test-python, test-frontend]
if: always()
steps:
- name: Check all CI jobs passed
run: |
echo "CI Job Results:"
echo " test-python: ${{ needs.test-python.result }}"
echo " test-frontend: ${{ needs.test-frontend.result }}"
echo " test-platform-integration: ${{ needs.test-platform-integration.result }}"
echo " test-python: ${{ needs.test-python.result }}"
echo " test-frontend: ${{ needs.test-frontend.result }}"
echo ""
if [[ "${{ needs.test-python.result }}" != "success" ]] || \
[[ "${{ needs.test-frontend.result }}" != "success" ]] || \
[[ "${{ needs.test-platform-integration.result }}" != "success" ]]; then
[[ "${{ needs.test-frontend.result }}" != "success" ]]; then
echo "❌ One or more CI jobs failed"
exit 1
fi
+29 -25
View File
@@ -3,15 +3,29 @@ name: Lint
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/frontend/biome.jsonc'
- '.pre-commit-config.yaml'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/frontend/biome.jsonc'
- '.pre-commit-config.yaml'
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting (Ruff)
# Python linting (Ruff) - already fast, no changes needed
python:
name: Python (Ruff)
runs-on: ubuntu-latest
@@ -24,7 +38,7 @@ jobs:
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
# Pin ruff version to match .pre-commit-config.yaml
- name: Install ruff
run: pip install ruff==0.14.10
@@ -34,39 +48,29 @@ jobs:
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
# TypeScript/JavaScript linting (ESLint)
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
typescript:
name: TypeScript (ESLint)
name: TypeScript (Biome)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
# Pin version to match package.json for consistent behavior
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
node-version: '24'
version: 2.3.11
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-lint-${{ hashFiles('apps/frontend/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-lint-
- name: Install dependencies
- name: Run Biome
working-directory: apps/frontend
run: npm ci --ignore-scripts
# biome ci fails on errors by default; warnings are reported but don't block
# Use --error-on-warnings when ready to enforce all rules
run: biome ci .
- name: Run ESLint
working-directory: apps/frontend
run: npm run lint
# Gate job for branch protection
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
lint-complete:
name: Lint Complete
runs-on: ubuntu-latest
+34 -31
View File
@@ -1,14 +1,30 @@
name: Quality Security
# CodeQL is slow (20-30 min per language), so:
# - Run on push to main only (not PRs or develop)
# - Run weekly scheduled scan
# Bandit is fast (5-10 min), so keep it on all PRs
on:
push:
branches: [main, develop]
branches: [main]
paths:
- 'apps/**'
- 'tests/**'
- 'pyproject.toml'
- 'package.json'
- '.github/workflows/quality-security.yml'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'pyproject.toml'
- 'package.json'
- '.github/workflows/quality-security.yml'
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
# Cancel in-progress runs for the same branch/PR
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -19,10 +35,13 @@ permissions:
actions: read
jobs:
# CodeQL only on push to main or scheduled (NOT on PRs - saves 40-60 min per PR)
codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
# Only run on push to main or scheduled - skip PRs for speed
if: github.event_name == 'push' || github.event_name == 'schedule'
strategy:
fail-fast: false
matrix:
@@ -45,6 +64,7 @@ jobs:
with:
category: "/language:${{ matrix.language }}"
# Bandit runs on all PRs - it's fast (5-10 min)
python-security:
name: Python Security (Bandit)
runs-on: ubuntu-latest
@@ -65,8 +85,6 @@ jobs:
id: bandit
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
@@ -80,7 +98,6 @@ jobs:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
@@ -89,38 +106,23 @@ jobs:
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` 🔴 HIGH: ${high.length}`);
console.log(` 🟡 MEDIUM: ${medium.length}`);
console.log(` 🟢 LOW: ${low.length}`);
console.log('');
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
}
}
console.log(` HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}`);
console.log('::endgroup::');
// Build summary
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
let summary = `## Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| 🔴 High | ${high.length} |\n`;
summary += `| 🟡 Medium | ${medium.length} |\n`;
summary += `| 🟢 Low | ${low.length} |\n\n`;
summary += `| High | ${high.length} |\n`;
summary += `| Medium | ${medium.length} |\n`;
summary += `| Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
@@ -134,14 +136,15 @@ jobs:
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('No high severity security issues found');
console.log('No high severity security issues found');
}
# Summary job that waits for all security checks
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
security-summary:
name: Security Summary
runs-on: ubuntu-latest
@@ -160,7 +163,7 @@ jobs:
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
+97 -202
View File
@@ -1,5 +1,10 @@
name: Release
# Triggers on version tags (v*) to build and publish releases
#
# IMPORTANT: If branch protection is enabled on 'main', the update-readme job
# requires a PAT or GitHub App token with bypass permissions to push directly.
# Currently uses GITHUB_TOKEN which works if "Allow GitHub Actions to create
# and approve pull requests" is enabled OR branch protection is not configured.
on:
push:
@@ -18,6 +23,9 @@ jobs:
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
build-macos-intel:
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
@@ -26,23 +34,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
@@ -80,27 +73,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- 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: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -115,6 +94,9 @@ jobs:
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
@@ -123,23 +105,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -174,27 +141,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- 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: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -222,24 +175,8 @@ jobs:
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: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -404,23 +341,8 @@ jobs:
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 Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak
run: |
@@ -472,8 +394,50 @@ jobs:
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v4
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v4
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [build-macos-intel, build-macos-arm64, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -491,8 +455,20 @@ jobs:
run: |
mkdir -p release-assets
# Copy binary artifacts (these have unique names per architecture)
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Validate that installer files exist
installer_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)
@@ -675,95 +651,14 @@ jobs:
- name: Update README.md
run: |
python3 << 'EOF'
import re
import sys
VERSION="${{ steps.version.outputs.version }}"
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
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
if [ "$IS_PRERELEASE" = "true" ]; then
python3 scripts/update-readme.py "$VERSION" --prerelease
else
python3 scripts/update-readme.py "$VERSION"
fi
echo "--- Verifying update ---"
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
+7 -7
View File
@@ -126,24 +126,24 @@ repos:
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
pass_filenames: false
# Frontend linting (apps/frontend/)
# Frontend linting (apps/frontend/) - Biome is 15-25x faster than ESLint
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
- repo: local
hooks:
- id: eslint
name: ESLint
- id: biome
name: Biome (lint + format)
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
echo "Skipping ESLint in worktree (node_modules not found)"
echo "Skipping Biome in worktree (node_modules not found)"
exit 0
fi
cd apps/frontend && npm run lint
cd apps/frontend && npx biome check --write --no-errors-on-unmatched .
language: system
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
files: ^apps/frontend/.*\.(ts|tsx|js|jsx|json)$
pass_filenames: false
- id: typecheck
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"assist": {
"enabled": false
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": "warn",
"complexity": {
"recommended": true,
"noBannedTypes": "off",
"noExcessiveLinesPerFunction": "off",
"useLiteralKeys": "off",
"useArrowFunction": "off"
},
"correctness": {
"recommended": true,
"noNodejsModules": "off",
"useImportExtensions": "off",
"noUnusedFunctionParameters": "warn",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn"
},
"security": {
"recommended": true,
// noSecrets: disabled due to excessive false positives (2700+ warnings)
// It flags normal strings like "Settings", "Integrations", etc. as potential secrets
"noSecrets": "off",
// noDangerouslySetInnerHtml: warn (not error) because this Electron app has legitimate
// uses for dangerouslySetInnerHTML (e.g., rendering sanitized markdown in terminal output,
// code highlighting). All usages are reviewed and sanitized. Set to warn for visibility.
"noDangerouslySetInnerHtml": "warn"
},
"style": {
"recommended": true,
"noDefaultExport": "off",
"useNamingConvention": "off",
"noProcessEnv": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off",
"useTemplate": "off"
},
"suspicious": {
"recommended": true,
"noConsole": "off",
"noEmptyBlockStatements": "warn",
"noAssignInExpressions": "warn",
"useAwait": "off",
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"useIterableCallbackReturn": "off",
"noControlCharactersInRegex": "warn",
"noArrayIndexKey": "warn",
"noShadowRestrictedNames": "warn",
"noRedeclare": "warn",
"noSelfCompare": "warn"
}
}
},
// Formatter disabled - using Prettier for formatting
// Biome linter used only for linting, keeping formatter separate
"formatter": {
"enabled": false
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "**/*.json"],
"ignoreUnknown": true
}
}
+3 -3
View File
@@ -6,12 +6,12 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: '.',
testMatch: '**/*.e2e.ts',
timeout: 60000,
timeout: 60_000,
expect: {
timeout: 10000
timeout: 10_000
},
fullyParallel: false, // Run tests serially for Electron
forbidOnly: !!process.env.CI,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: 1, // Single worker for Electron
reporter: 'html',
-97
View File
@@ -1,97 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
plugins: {
react: react,
'react-hooks': reactHooks
},
settings: {
react: {
version: 'detect'
}
},
rules: {
// TypeScript
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
// React
...react.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
// React Hooks - only classic rules, no compiler rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// General
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'warn',
'no-unused-expressions': 'warn',
'@typescript-eslint/no-require-imports': 'warn'
}
},
{
files: ['src/main/**/*.ts'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'@typescript-eslint/no-require-imports': 'off'
}
},
{
files: ['src/preload/**/*.ts'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.cjs'],
languageOptions: {
globals: {
...globals.node,
module: 'readonly',
require: 'readonly',
__dirname: 'readonly',
process: 'readonly',
console: 'readonly'
},
sourceType: 'commonjs'
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'no-undef': 'off'
}
},
{
ignores: ['out/**', 'dist/**', '.eslintrc.cjs', 'eslint.config.mjs', 'node_modules/**', 'python-runtime/**']
}
);
+10 -12
View File
@@ -43,8 +43,9 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "npx playwright test --config=e2e/playwright.config.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -101,10 +102,10 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@electron/rebuild": "^4.0.2",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.0.0",
@@ -120,25 +121,22 @@
"autoprefixer": "^10.4.22",
"cross-env": "^10.1.0",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"electron-builder": "^26.4.0",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.0.0",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"vite": "^7.2.7",
"vitest": "^4.0.16"
},
"overrides": {
"tar": "^7.5.3",
"electron-builder-squirrel-windows": "^26.0.12",
"dmg-builder": "^26.0.12"
"dmg-builder": "^26.0.12",
"@electron/rebuild": "4.0.2"
},
"build": {
"appId": "com.autoclaude.ui",
@@ -237,8 +235,8 @@
}
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix"
"*.{ts,tsx,js,jsx,json}": [
"biome check --write --no-errors-on-unmatched"
]
}
}
@@ -62,7 +62,7 @@ if (pythonResource) {
console.log('\n3. Checking venv creation capability...');
try {
// Find system Python for testing
let pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
const result = spawnSync(pythonCmd, ['-m', 'venv', '--help'], { encoding: 'utf8' });
if (result.status === 0) {
+1 -1
View File
@@ -38,7 +38,7 @@ export async function existsAsync(filePath: string): Promise<boolean> {
}
// Cache for npm global prefix to avoid repeated async calls
let npmGlobalPrefixCache: string | null | undefined = undefined;
let npmGlobalPrefixCache: string | null | undefined ;
let npmGlobalPrefixCachePromise: Promise<string | null> | null = null;
/**
@@ -745,7 +745,6 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
console.log('[Claude Code] Opened terminal:', cmd);
break;
} catch {
continue;
}
}
@@ -47,7 +47,6 @@ export function loadGraphitiStateFromSpecs(
const stateContent = readFileSync(statePath, 'utf-8');
return JSON.parse(stateContent);
} catch {
continue;
}
}
}
@@ -62,19 +62,21 @@ const mockCreateIPCCommunicators = vi.fn(
const projectRef: { current: Project | null } = { current: null };
const tempDirs: string[] = [];
class MockBrowserWindow {}
vi.mock('electron', () => ({
ipcMain: mockIpcMain,
BrowserWindow: class {},
BrowserWindow: MockBrowserWindow,
app: {
getPath: vi.fn(() => '/tmp'),
on: vi.fn(),
},
}));
class MockAgentManager {
startSpecCreation = vi.fn();
}
vi.mock('../../../agent/agent-manager', () => ({
AgentManager: class {
startSpecCreation = vi.fn();
},
AgentManager: MockAgentManager,
}));
vi.mock('../utils/ipc-communicator', () => ({
@@ -504,8 +504,6 @@ export function registerSettingsHandlers(
opened = true;
break;
} catch {
// Try next terminal
continue;
}
}
@@ -1610,7 +1610,7 @@ async function withRetry<T>(
onRetry?.(attempt, error);
// Wait before retry (exponential backoff)
await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt - 1)));
await new Promise(r => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
}
}
@@ -93,12 +93,10 @@ export function findPythonCommand(): string | null {
return cmd;
} else {
console.warn(`[Python] ${cmd} version too old: ${validation.message}`);
continue;
}
} catch {
// Command not found or errored, try next
console.warn(`[Python] Command not found or errored: ${cmd}`);
continue;
}
}
@@ -231,7 +231,7 @@ class PtyDaemonClient {
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000);
console.warn(
`[PtyDaemonClient] Reconnect attempt ${this.reconnectAttempts} in ${delay}ms...`
@@ -66,7 +66,6 @@ export function findHomebrewPython(
} catch (error) {
// Version check failed (e.g., timeout, permission issue), try next candidate
console.warn(`${logPrefix} Failed to validate ${pythonPath}: ${error}`);
continue;
}
}
}
@@ -19,8 +19,7 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
@@ -31,8 +30,7 @@ export function Toaster() {
{action}
<ToastClose />
</Toast>
);
})}
))}
<ToastViewport />
</ToastProvider>
);
+370 -2832
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -45,6 +45,7 @@
"jsdom": "^27.4.0"
},
"overrides": {
"tar": "^7.5.3"
"tar": "^7.5.3",
"@electron/rebuild": "4.0.2"
}
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Update README.md version badges and download links.
Usage:
python scripts/update-readme.py <version> [--prerelease]
Examples:
python scripts/update-readme.py 2.8.0 # Stable release
python scripts/update-readme.py 2.8.0-beta.1 --prerelease # Beta release
"""
import argparse
import re
import sys
# Semver pattern: X.Y.Z or X.Y.Z-prerelease.N
SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+(-[a-zA-Z]+\.\d+)?$")
def validate_version(version: str) -> bool:
"""Validate version string matches semver format."""
return bool(SEMVER_PATTERN.match(version))
def update_section(text: str, start_marker: str, end_marker: str, replacements: list) -> str:
"""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)
def update_readme(version: str, is_prerelease: bool) -> bool:
"""
Update README.md with new version.
Args:
version: Version string (e.g., "2.8.0" or "2.8.0-beta.1")
is_prerelease: Whether this is a prerelease version
Returns:
True if changes were made, False otherwise
"""
# Shields.io escapes hyphens as --
version_badge = version.replace("-", "--")
# Read README
with open("README.md", "r") as f:
original_content = f.read()
content = original_content
# 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.]+)?"
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}/"),
],
)
# Check if changes were made
if content == original_content:
print("No changes needed")
return False
# Write updated README
with open("README.md", "w") as f:
f.write(content)
print(f"README.md updated for {version} (prerelease={is_prerelease})")
return True
def main():
parser = argparse.ArgumentParser(description="Update README.md version badges and download links")
parser.add_argument("version", help="Version string (e.g., 2.8.0 or 2.8.0-beta.1)")
parser.add_argument("--prerelease", action="store_true", help="Mark as prerelease version")
args = parser.parse_args()
# Validate version format
if not validate_version(args.version):
print(f"ERROR: Invalid version format: {args.version}", file=sys.stderr)
print("Expected format: X.Y.Z or X.Y.Z-prerelease.N (e.g., 2.8.0 or 2.8.0-beta.1)", file=sys.stderr)
sys.exit(1)
# Auto-detect prerelease if not explicitly set
is_prerelease = args.prerelease or ("-" in args.version)
try:
changed = update_readme(args.version, is_prerelease)
sys.exit(0 if changed else 0) # Exit 0 in both cases (no error)
except FileNotFoundError:
print("ERROR: README.md not found", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()