Skip to content

fix: generate proper appcast.xml with architecture-specific Sparkle i… #59

fix: generate proper appcast.xml with architecture-specific Sparkle i…

fix: generate proper appcast.xml with architecture-specific Sparkle i… #59

Workflow file for this run

name: Build TablePro
on:
push:
tags: ["v*"]
paths-ignore:
- "**.md"
- "docs/**"
- ".vscode/**"
env:
XCODE_PROJECT: TablePro.xcodeproj
XCODE_SCHEME: TablePro
BUILD_CONFIGURATION: Release
XCODE_VERSION: "26.2" # Updated to support macOS 26 Tahoe liquid design
jobs:
lint:
name: SwiftLint
runs-on: macos-latest # Updated to macOS 26 (Tahoe) runner
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache SwiftLint
uses: actions/cache@v4
with:
path: ~/Library/Caches/Homebrew/swiftlint*
key: swiftlint-${{ runner.os }}-${{ hashFiles('.swiftlint.yml') }}
restore-keys: |
swiftlint-${{ runner.os }}-
- name: Install SwiftLint
run: brew install swiftlint
- name: Run SwiftLint
run: swiftlint lint --strict
build-arm64:
name: Build ARM64
runs-on: macos-latest # Updated to macOS 26 (Tahoe) runner
needs: lint
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache Homebrew downloads
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/Library/Caches/Homebrew
key: brew-arm64-downloads-${{ runner.os }}-${{ hashFiles('**/scripts/build-release.sh') }}
restore-keys: |
brew-arm64-downloads-${{ runner.os }}-
- name: Install ARM64 dependencies
run: |
echo "Installing ARM64 dependencies..."
# Check and install only if needed
if ! brew list mariadb-connector-c &>/dev/null; then
echo "📦 Installing mariadb-connector-c..."
brew install mariadb-connector-c
else
echo "✅ mariadb-connector-c already installed"
fi
if ! brew list libpq &>/dev/null; then
echo "📦 Installing libpq..."
brew install libpq
else
echo "✅ libpq already installed"
fi
# Link packages with --force (needed for keg-only formulas)
brew link --force mariadb-connector-c
brew link --force libpq
# Verify installations
if ! brew list mariadb-connector-c >/dev/null 2>&1; then
echo "❌ ERROR: mariadb-connector-c installation failed"
exit 1
fi
if ! brew list libpq >/dev/null 2>&1; then
echo "❌ ERROR: libpq installation failed"
exit 1
fi
echo "✅ ARM64 dependencies installed"
- name: Prepare libmariadb
run: |
echo "📦 Preparing libmariadb.a for arm64..."
cp Libs/libmariadb_arm64.a Libs/libmariadb.a
echo "✅ libmariadb.a ready"
lipo -info Libs/libmariadb.a
ls -lh Libs/libmariadb.a
- name: Select Xcode version
run: |
sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer
echo "Selected Xcode version:"
xcodebuild -version
- name: Build ARM64
run: |
chmod +x scripts/build-release.sh
scripts/build-release.sh arm64
- name: Verify build
run: |
echo "Verifying build output..."
BINARY_PATH="build/Release/TablePro-arm64.app/Contents/MacOS/TablePro"
# Check binary exists
if [ ! -f "$BINARY_PATH" ]; then
echo "❌ ERROR: Built binary not found at: $BINARY_PATH"
echo "Build may have failed silently"
exit 1
fi
# Check it's not empty
if [ ! -s "$BINARY_PATH" ]; then
echo "❌ ERROR: Binary file is empty"
exit 1
fi
# Check architecture
ARCH_INFO=$(lipo -info "$BINARY_PATH")
echo "Architecture: $ARCH_INFO"
if ! echo "$ARCH_INFO" | grep -q "arm64"; then
echo "❌ ERROR: Binary does not contain arm64 architecture"
echo "Expected: arm64 only"
echo "Got: $ARCH_INFO"
exit 1
fi
if echo "$ARCH_INFO" | grep -q "x86_64"; then
echo "❌ ERROR: Binary contains x86_64 but should be arm64 only"
exit 1
fi
# Check it's executable
if [ ! -x "$BINARY_PATH" ]; then
echo "❌ ERROR: Binary is not executable"
exit 1
fi
# Display info
echo "✅ Build verified successfully"
echo "Binary size: $(ls -lh "$BINARY_PATH" | awk '{print $5}')"
echo "App bundle size: $(du -sh build/Release/TablePro-arm64.app | awk '{print $1}')"
- name: Create DMG installer
run: |
echo "Creating DMG installer..."
# Install create-dmg tool for proper icon handling in CI
echo "📦 Installing create-dmg tool..."
brew install create-dmg
# Make DMG creation script executable
chmod +x scripts/create-dmg.sh
# Create DMG with version from git tag or use default
# The script handles app renaming internally
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.1.13")
echo "📌 Using version: $VERSION"
scripts/create-dmg.sh "$VERSION" "arm64" "build/Release/TablePro-arm64.app"
# Verify DMG was created - check for the specific file or any arm64 DMG
DMG_FILE="build/Release/TablePro-${VERSION}-arm64.dmg"
if [ -f "$DMG_FILE" ]; then
echo "✅ DMG installer created successfully: $DMG_FILE"
else
echo "⚠️ Expected DMG not found at: $DMG_FILE"
echo "📂 Checking for any DMG files in build/Release/:"
ls -la build/Release/*.dmg 2>/dev/null || echo " No DMG files found"
# Check if any arm64 DMG was created (version might differ)
if ls build/Release/*-arm64.dmg 1>/dev/null 2>&1; then
echo "✅ Found arm64 DMG file(s):"
ls -lh build/Release/*-arm64.dmg
else
echo "❌ ERROR: No arm64 DMG file was created"
exit 1
fi
fi
ls -lh build/Release/*.dmg
- name: Create ZIP archive (fallback)
run: |
echo "Creating ZIP archive..."
cd build/Release
if ! zip -r TablePro-arm64.zip TablePro-arm64.app; then
echo "❌ ERROR: Failed to create ZIP archive"
exit 1
fi
echo "✅ ZIP archive created"
ls -lh TablePro-arm64.zip
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: TablePro-arm64-${{ github.sha }}
path: |
build/Release/*.dmg
build/Release/TablePro-arm64.zip
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
build-x86_64:
name: Build x86_64
runs-on: macos-latest # Updated to macOS 26 (Tahoe) runner
needs: lint
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Cache Homebrew downloads
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/Library/Caches/Homebrew
key: brew-x86_64-downloads-${{ runner.os }}-${{ hashFiles('**/scripts/build-release.sh') }}
restore-keys: |
brew-x86_64-downloads-${{ runner.os }}-
- name: Install Rosetta 2
run: |
if ! arch -x86_64 /usr/bin/true 2>/dev/null; then
echo "Installing Rosetta 2..."
if ! softwareupdate --install-rosetta --agree-to-license; then
echo "❌ ERROR: Failed to install Rosetta 2"
exit 1
fi
# Verify Rosetta 2 works
if ! arch -x86_64 /usr/bin/true 2>/dev/null; then
echo "❌ ERROR: Rosetta 2 installed but not functional"
exit 1
fi
echo "✅ Rosetta 2 installed"
else
echo "✅ Rosetta 2 already installed"
fi
- name: Install x86_64 Homebrew
run: |
if [ ! -f /usr/local/bin/brew ]; then
echo "Installing x86_64 Homebrew..."
if ! arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then
echo "❌ ERROR: Homebrew installation failed"
exit 1
fi
if [ ! -f /usr/local/bin/brew ]; then
echo "❌ ERROR: Homebrew not found after installation"
exit 1
fi
if ! /usr/local/bin/brew --version; then
echo "❌ ERROR: Homebrew not functional"
exit 1
fi
echo "✅ x86_64 Homebrew installed"
else
echo "x86_64 Homebrew already installed"
if ! /usr/local/bin/brew --version; then
echo "❌ ERROR: Homebrew not functional"
exit 1
fi
fi
- name: Install x86_64 dependencies
run: |
echo "Installing x86_64 dependencies..."
# Check and install only if needed
if ! arch -x86_64 /usr/local/bin/brew list mariadb-connector-c &>/dev/null; then
echo "📦 Installing mariadb-connector-c (x86_64)..."
arch -x86_64 /usr/local/bin/brew install mariadb-connector-c
else
echo "✅ mariadb-connector-c (x86_64) already installed"
fi
if ! arch -x86_64 /usr/local/bin/brew list libpq &>/dev/null; then
echo "📦 Installing libpq (x86_64)..."
arch -x86_64 /usr/local/bin/brew install libpq
else
echo "✅ libpq (x86_64) already installed"
fi
# Link packages with --force (needed for keg-only formulas)
arch -x86_64 /usr/local/bin/brew link --force mariadb-connector-c
arch -x86_64 /usr/local/bin/brew link --force libpq
# Verify installations
if ! arch -x86_64 /usr/local/bin/brew list mariadb-connector-c >/dev/null 2>&1; then
echo "❌ ERROR: mariadb-connector-c installation failed"
exit 1
fi
if ! arch -x86_64 /usr/local/bin/brew list libpq >/dev/null 2>&1; then
echo "❌ ERROR: libpq installation failed"
exit 1
fi
echo "✅ x86_64 dependencies installed"
- name: Prepare libmariadb
run: |
echo "📦 Preparing libmariadb.a for x86_64..."
cp Libs/libmariadb_x86_64.a Libs/libmariadb.a
echo "✅ libmariadb.a ready"
lipo -info Libs/libmariadb.a
ls -lh Libs/libmariadb.a
- name: Select Xcode version
run: |
sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer
echo "Selected Xcode version:"
xcodebuild -version
- name: Build x86_64
run: |
chmod +x scripts/build-release.sh
scripts/build-release.sh x86_64
- name: Verify build
run: |
echo "Verifying build output..."
BINARY_PATH="build/Release/TablePro-x86_64.app/Contents/MacOS/TablePro"
# Check binary exists
if [ ! -f "$BINARY_PATH" ]; then
echo "❌ ERROR: Built binary not found at: $BINARY_PATH"
exit 1
fi
# Check it's not empty
if [ ! -s "$BINARY_PATH" ]; then
echo "❌ ERROR: Binary file is empty"
exit 1
fi
# Check architecture
ARCH_INFO=$(lipo -info "$BINARY_PATH")
echo "Architecture: $ARCH_INFO"
if ! echo "$ARCH_INFO" | grep -q "x86_64"; then
echo "❌ ERROR: Binary does not contain x86_64 architecture"
echo "Expected: x86_64 only"
echo "Got: $ARCH_INFO"
exit 1
fi
if echo "$ARCH_INFO" | grep -q "arm64"; then
echo "❌ ERROR: Binary contains arm64 but should be x86_64 only"
exit 1
fi
# Check it's executable
if [ ! -x "$BINARY_PATH" ]; then
echo "❌ ERROR: Binary is not executable"
exit 1
fi
# Display info
echo "✅ Build verified successfully"
echo "Binary size: $(ls -lh "$BINARY_PATH" | awk '{print $5}')"
echo "App bundle size: $(du -sh build/Release/TablePro-x86_64.app | awk '{print $1}')"
- name: Create DMG installer
run: |
echo "Creating DMG installer..."
# Install create-dmg tool for proper icon handling in CI
echo "📦 Installing create-dmg tool..."
brew install create-dmg
# Make DMG creation script executable
chmod +x scripts/create-dmg.sh
# Create DMG with version from git tag or use default
# The script handles app renaming internally
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.1.13")
echo "📌 Using version: $VERSION"
scripts/create-dmg.sh "$VERSION" "x86_64" "build/Release/TablePro-x86_64.app"
# Verify DMG was created - check for the specific file or any x86_64 DMG
DMG_FILE="build/Release/TablePro-${VERSION}-x86_64.dmg"
if [ -f "$DMG_FILE" ]; then
echo "✅ DMG installer created successfully: $DMG_FILE"
else
echo "⚠️ Expected DMG not found at: $DMG_FILE"
echo "📂 Checking for any DMG files in build/Release/:"
ls -la build/Release/*.dmg 2>/dev/null || echo " No DMG files found"
# Check if any x86_64 DMG was created (version might differ)
if ls build/Release/*-x86_64.dmg 1>/dev/null 2>&1; then
echo "✅ Found x86_64 DMG file(s):"
ls -lh build/Release/*-x86_64.dmg
else
echo "❌ ERROR: No x86_64 DMG file was created"
exit 1
fi
fi
ls -lh build/Release/*.dmg
- name: Create ZIP archive (fallback)
run: |
echo "Creating ZIP archive..."
cd build/Release
if ! zip -r TablePro-x86_64.zip TablePro-x86_64.app; then
echo "❌ ERROR: Failed to create ZIP archive"
exit 1
fi
echo "✅ ZIP archive created"
ls -lh TablePro-x86_64.zip
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: TablePro-x86_64-${{ github.sha }}
path: |
build/Release/*.dmg
build/Release/TablePro-x86_64.zip
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
release:
name: Create GitHub Release
runs-on: macos-latest # Updated to macOS 26 (Tahoe) runner
needs: [build-arm64, build-x86_64]
if: startsWith(github.ref, 'refs/tags/v')
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download ARM64 artifact
uses: actions/download-artifact@v4
with:
name: TablePro-arm64-${{ github.sha }}
path: artifacts/
- name: Download x86_64 artifact
uses: actions/download-artifact@v4
with:
name: TablePro-x86_64-${{ github.sha }}
path: artifacts/
- name: Verify and organize artifacts for release
run: |
VERSION=${GITHUB_REF#refs/tags/v}
if [ -z "$VERSION" ]; then
echo "❌ ERROR: Failed to extract version from ref: $GITHUB_REF"
exit 1
fi
echo "Preparing artifacts for version: $VERSION"
echo "Contents of artifacts directory:"
ls -la artifacts/
# Note: DMG files should already have correct names from build
# ZIP files need to be renamed
# Rename ZIP files if they exist
if [ -f "artifacts/TablePro-arm64.zip" ]; then
mv artifacts/TablePro-arm64.zip "artifacts/TablePro-${VERSION}-arm64.zip"
fi
if [ -f "artifacts/TablePro-x86_64.zip" ]; then
mv artifacts/TablePro-x86_64.zip "artifacts/TablePro-${VERSION}-x86_64.zip"
fi
echo "✅ Artifacts organized successfully"
echo "Final artifacts:"
ls -lh artifacts/
- name: Sign update archives with Sparkle
if: env.SPARKLE_PRIVATE_KEY != ''
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
VERSION=${GITHUB_REF#refs/tags/v}
# Install Sparkle tools (Cask — binaries in Caskroom, not on PATH)
brew install --cask sparkle
SPARKLE_BIN="$(brew --caskroom)/sparkle/$(ls "$(brew --caskroom)/sparkle" | head -1)/bin"
ARM64_ZIP="artifacts/TablePro-${VERSION}-arm64.zip"
X86_64_ZIP="artifacts/TablePro-${VERSION}-x86_64.zip"
# Sign each ZIP with EdDSA using sign_update
KEY_FILE=$(mktemp)
echo "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE"
ARM64_SIG=$("$SPARKLE_BIN/sign_update" "$ARM64_ZIP" -f "$KEY_FILE")
X86_64_SIG=$("$SPARKLE_BIN/sign_update" "$X86_64_ZIP" -f "$KEY_FILE")
rm -f "$KEY_FILE"
# Parse signature and length from sign_update output
# Output format: sparkle:edSignature="..." length="..."
ARM64_ED_SIG=$(echo "$ARM64_SIG" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
ARM64_LENGTH=$(echo "$ARM64_SIG" | sed -n 's/.*length="\([^"]*\)".*/\1/p')
X86_64_ED_SIG=$(echo "$X86_64_SIG" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
X86_64_LENGTH=$(echo "$X86_64_SIG" | sed -n 's/.*length="\([^"]*\)".*/\1/p')
# Extract version info from the app's Info.plist inside the ZIP
TEMP_DIR=$(mktemp -d)
unzip -q "$ARM64_ZIP" "*/Contents/Info.plist" -d "$TEMP_DIR"
INFO_PLIST=$(find "$TEMP_DIR" -name "Info.plist" -path "*/Contents/Info.plist" | head -1)
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$INFO_PLIST")
SHORT_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO_PLIST")
MIN_OS=$(/usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "$INFO_PLIST" 2>/dev/null || echo "13.5")
rm -rf "$TEMP_DIR"
# Build appcast.xml with architecture-specific items (Sparkle 2 convention)
# Each item has sparkle:architectures on the enclosure so the client
# automatically picks the matching architecture
DOWNLOAD_PREFIX="https://github.com/datlechin/TablePro/releases/download/v${VERSION}"
PUB_DATE=$(date -u '+%a, %d %b %Y %H:%M:%S +0000')
mkdir -p appcast
cat > appcast/appcast.xml << APPCAST_EOF
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>TablePro</title>
<item>
<title>${SHORT_VERSION}</title>
<pubDate>${PUB_DATE}</pubDate>
<sparkle:version>${BUILD_NUMBER}</sparkle:version>
<sparkle:shortVersionString>${SHORT_VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>${MIN_OS}</sparkle:minimumSystemVersion>
<enclosure url="${DOWNLOAD_PREFIX}/TablePro-${VERSION}-arm64.zip" length="${ARM64_LENGTH}" type="application/octet-stream" sparkle:edSignature="${ARM64_ED_SIG}" sparkle:architectures="arm64"/>
</item>
<item>
<title>${SHORT_VERSION}</title>
<pubDate>${PUB_DATE}</pubDate>
<sparkle:version>${BUILD_NUMBER}</sparkle:version>
<sparkle:shortVersionString>${SHORT_VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>${MIN_OS}</sparkle:minimumSystemVersion>
<enclosure url="${DOWNLOAD_PREFIX}/TablePro-${VERSION}-x86_64.zip" length="${X86_64_LENGTH}" type="application/octet-stream" sparkle:edSignature="${X86_64_ED_SIG}" sparkle:architectures="x86_64"/>
</item>
</channel>
</rss>
APPCAST_EOF
echo "✅ Appcast generated with architecture-specific items:"
cat appcast/appcast.xml
- name: Upload appcast artifact
if: env.SPARKLE_PRIVATE_KEY != ''
uses: actions/upload-artifact@v4
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
with:
name: appcast-${{ github.sha }}
path: appcast/appcast.xml
retention-days: 90
- name: Commit appcast.xml to repo
if: env.SPARKLE_PRIVATE_KEY != ''
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
if [ ! -f appcast/appcast.xml ]; then
echo "⚠️ No appcast.xml to commit"
exit 0
fi
cp appcast/appcast.xml appcast.xml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add appcast.xml
git diff --cached --quiet && echo "No changes to appcast.xml" && exit 0
git commit -m "Update appcast.xml for v${GITHUB_REF#refs/tags/v}"
git push origin HEAD:main
- name: Extract release notes from CHANGELOG.md
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "Extracting release notes for version: $VERSION"
# Extract the section for this version from CHANGELOG.md
# Matches from "## [X.Y.Z]" until the next "## [" or end of file
NOTES=$(awk -v ver="$VERSION" '
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" ver "\\]") { found=1; next }
}
found { print }
' CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "⚠️ No changelog entry found for version $VERSION, using fallback"
echo "- Bug fixes and improvements" > release_notes.md
else
echo "$NOTES" > release_notes.md
fi
echo "✅ Release notes extracted"
cat release_notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/*.dmg
artifacts/*.zip
body_path: release_notes.md
draft: false
prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-rc') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}