Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for edge-tts network requests -->
<key>com.apple.security.network.client</key>
<true/>

<!-- Required for reading PDF files from user-selected locations -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>

<!-- Required for writing MP3 files to user-selected locations -->
<key>com.apple.security.files.downloads.read-write</key>
<true/>

<!-- Enable hardened runtime - required for Python runtime -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>

<!-- Required for Python runtime and bundled libraries -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
217 changes: 217 additions & 0 deletions .github/scripts/sign-and-notarize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/bin/bash
set -e

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}

log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}

log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}

# Check required environment variables
if [ -z "$APPLE_CERTIFICATE_P12_BASE64" ]; then
log_error "APPLE_CERTIFICATE_P12_BASE64 is not set"
exit 1
fi

if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then
log_error "APPLE_CERTIFICATE_PASSWORD is not set"
exit 1
fi

if [ -z "$APPLE_ID" ]; then
log_error "APPLE_ID is not set"
exit 1
fi

if [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ]; then
log_error "APPLE_APP_SPECIFIC_PASSWORD is not set"
exit 1
fi

if [ -z "$APPLE_TEAM_ID" ]; then
log_error "APPLE_TEAM_ID is not set"
exit 1
fi

APP_PATH="dist/TextWave.app"
ENTITLEMENTS_PATH=".github/entitlements.plist"

# Verify app exists
if [ ! -d "$APP_PATH" ]; then
log_error "App not found at $APP_PATH"
exit 1
fi

log_info "Starting code signing and notarization process..."

# Create temporary keychain
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)

log_info "Creating temporary keychain..."
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

# Decode and import certificate
log_info "Importing certificate..."
CERT_PATH="$RUNNER_TEMP/certificate.p12"
echo "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign

# Set keychain as default
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

# Find signing identity
log_info "Finding Developer ID Application certificate..."
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"')

if [ -z "$SIGNING_IDENTITY" ]; then
log_error "No Developer ID Application certificate found"
security delete-keychain "$KEYCHAIN_PATH" || true
exit 1
fi

log_info "Using signing identity: $SIGNING_IDENTITY"

# Sign all binaries - must be done in correct order: deepest first
log_info "Signing all binaries (this may take a few minutes)..."

# Step 1: Sign all .dylib and .so files
log_info "Step 1/5: Signing .dylib and .so files..."
find "$APP_PATH/Contents/Resources" -type f \( -name "*.dylib" -o -name "*.so" \) -print0 | while IFS= read -r -d '' file; do
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$file" 2>/dev/null || true
done

# Step 2: Sign all executables in MacOS directory (including Python)
log_info "Step 2/5: Signing executables in MacOS directory..."
find "$APP_PATH/Contents/MacOS" -type f -perm +111 -print0 | while IFS= read -r -d '' file; do
log_info " Signing: $(basename "$file")"
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$file" 2>/dev/null || true
done

# Step 3: Sign Qt/PyQt6 frameworks (must sign nested binaries first, then framework)
log_info "Step 3/5: Signing Qt frameworks..."
if [ -d "$APP_PATH/Contents/Resources/lib/python3.11/PyQt6/Qt6/lib" ]; then
find "$APP_PATH/Contents/Resources/lib/python3.11/PyQt6/Qt6/lib" -name "*.framework" -print0 | while IFS= read -r -d '' framework; do
# First sign any binaries inside the framework
find "$framework" -type f \( -name "*.dylib" -o -perm +111 \) -print0 | while IFS= read -r -d '' binary; do
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$binary" 2>/dev/null || true
done
# Then sign the framework itself
log_info " Signing framework: $(basename "$framework")"
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$framework" 2>/dev/null || true
done
fi

# Step 4: Sign Contents/Frameworks (both .dylib files and .framework bundles)
log_info "Step 4/5: Signing Contents/Frameworks..."
if [ -d "$APP_PATH/Contents/Frameworks" ]; then
# First sign all .dylib files
find "$APP_PATH/Contents/Frameworks" -type f -name "*.dylib" -print0 | while IFS= read -r -d '' dylib; do
log_info " Signing dylib: $(basename "$dylib")"
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$dylib" 2>/dev/null || true
done
# Then sign .framework bundles
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -print0 | while IFS= read -r -d '' framework; do
log_info " Signing framework: $(basename "$framework")"
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime "$framework" 2>/dev/null || true
done
fi

# Step 5: Sign the main executable
log_info "Step 5/5: Signing main executable..."
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
"$APP_PATH/Contents/MacOS/TextWave"

# Finally: Sign the app bundle itself
log_info "Signing app bundle..."
codesign --force --sign "$SIGNING_IDENTITY" --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
"$APP_PATH"

# Verify signature
log_info "Verifying signature..."
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH" || log_warning "Gatekeeper assessment may fail before notarization"

# Create zip for notarization (notarization requires zip, not just app bundle)
log_info "Creating archive for notarization..."
NOTARIZATION_ZIP="$RUNNER_TEMP/TextWave-notarization.zip"
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZATION_ZIP"

# Submit for notarization
log_info "Submitting to Apple notary service..."
NOTARIZATION_OUTPUT=$(xcrun notarytool submit "$NOTARIZATION_ZIP" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait \
--timeout 30m 2>&1)

NOTARIZATION_EXIT_CODE=$?
echo "$NOTARIZATION_OUTPUT"

# Extract submission ID from output
SUBMISSION_ID=$(echo "$NOTARIZATION_OUTPUT" | grep -o 'id: [a-f0-9-]*' | head -1 | cut -d' ' -f2)

# Check if notarization was accepted
if echo "$NOTARIZATION_OUTPUT" | grep -q "status: Accepted"; then
log_info "Notarization accepted!"
elif echo "$NOTARIZATION_OUTPUT" | grep -q "status: Invalid"; then
log_error "Notarization was rejected by Apple!"
if [ -n "$SUBMISSION_ID" ]; then
log_error "Fetching detailed rejection log..."
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" || true
fi
security delete-keychain "$KEYCHAIN_PATH" || true
exit 1
elif [ $NOTARIZATION_EXIT_CODE -ne 0 ]; then
log_error "Notarization failed with exit code $NOTARIZATION_EXIT_CODE"
security delete-keychain "$KEYCHAIN_PATH" || true
exit 1
else
log_error "Notarization status unclear - check output above"
security delete-keychain "$KEYCHAIN_PATH" || true
exit 1
fi

# Staple the notarization ticket
log_info "Stapling notarization ticket..."
xcrun stapler staple "$APP_PATH"

# Verify stapling
log_info "Verifying stapled ticket..."
xcrun stapler validate "$APP_PATH"

# Final verification
log_info "Final signature verification..."
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH"

log_info "Code signing and notarization complete!"

# Clean up
log_info "Cleaning up temporary keychain..."
security delete-keychain "$KEYCHAIN_PATH" || true
rm -f "$CERT_PATH"
rm -f "$NOTARIZATION_ZIP"

log_info "All done! The app is signed and notarized."
36 changes: 36 additions & 0 deletions .github/scripts/sign-dmg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/bash
set -e

VERSION=$1
DMG_PATH="TextWave-${VERSION}.dmg"

if [ -z "$VERSION" ]; then
echo "Error: Version not specified"
exit 1
fi

if [ ! -f "$DMG_PATH" ]; then
echo "Error: DMG not found at $DMG_PATH"
exit 1
fi

echo "Signing DMG: $DMG_PATH"

# Find signing identity (keychain already set up by calling workflow)
SIGNING_IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"')

if [ -z "$SIGNING_IDENTITY" ]; then
echo "Error: No Developer ID Application certificate found"
exit 1
fi

echo "Using signing identity: $SIGNING_IDENTITY"

# Sign the DMG
codesign --force --sign "$SIGNING_IDENTITY" --timestamp "$DMG_PATH"

# Verify DMG signature
echo "Verifying DMG signature..."
codesign --verify --verbose=2 "$DMG_PATH"

echo "DMG signed successfully!"
95 changes: 94 additions & 1 deletion .github/workflows/pr-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
push:
branches: [main]

permissions:
contents: read
pull-requests: write

jobs:
test:
runs-on: macos-latest
Expand All @@ -30,8 +34,24 @@ jobs:
flake8 pdf2mp3_gui.py --max-line-length=120 --ignore=E203,W503 || true

- name: Run unit tests
id: pytest
continue-on-error: true
run: |
pytest tests/ -v --cov=pdf2mp3_gui --cov-report=xml --cov-report=term
set +e
pytest tests/ -v --cov=pdf2mp3_gui --cov-report=xml --cov-report=term | tee pytest-output.txt
echo "exit_code=$?" >> $GITHUB_OUTPUT
exit 0

- name: Extract coverage percentage
id: coverage
run: |
# Extract just the percentage number from coverage output
COVERAGE=$(grep "TOTAL" pytest-output.txt 2>/dev/null | awk '{print $NF}' | tr -d '%' || echo "0")
# Validate it's a number
if ! [[ "$COVERAGE" =~ ^[0-9]+$ ]]; then
COVERAGE="0"
fi
echo "percentage=$COVERAGE" >> $GITHUB_OUTPUT

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand All @@ -42,7 +62,80 @@ jobs:
continue-on-error: true

- name: Test app builds
id: build
continue-on-error: true
run: |
set +e
python -m pip install py2app
python setup.py py2app
test -d dist/TextWave.app
echo "exit_code=$?" >> $GITHUB_OUTPUT
exit 0

- name: Post test results comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const pytest_exit = '${{ steps.pytest.outputs.exit_code }}' || '0';
const build_exit = '${{ steps.build.outputs.exit_code }}' || '0';
const coverage = '${{ steps.coverage.outputs.percentage }}' || '0';

// Determine overall status
const all_passed = pytest_exit === '0' && build_exit === '0';
const status_emoji = all_passed ? '✅' : '❌';
const status_text = all_passed ? 'Passed' : 'Failed';

// Create comment body
const comment_body = `## ${status_emoji} Test Results

**Status:** ${status_text}
**Coverage:** ${coverage}%

### Test Summary
- **Linting:** ✅ flake8 checks completed
- **Unit Tests:** ${pytest_exit === '0' ? '✅ Passed' : '❌ Failed'}
- **Build Verification:** ${build_exit === '0' ? '✅ Passed' : '❌ Failed'}
- **Signing & Notarization:** ⏭️ Only on release (not tested in PRs)

[View full workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId})

---
*Last updated: ${new Date().toUTCString()}*
<!-- test-results-comment -->`;

// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const bot_comment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- test-results-comment -->')
);

// Update or create comment
if (bot_comment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: bot_comment.id,
body: comment_body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment_body
});
}

- name: Fail workflow if tests failed
if: steps.pytest.outputs.exit_code != '0' || steps.build.outputs.exit_code != '0'
run: |
echo "Tests failed - failing workflow"
exit 1
Loading
Loading