Merge pull request #201 from supabitapp/sbertix/row-flashing #146
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Tip | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: {} | |
| concurrency: | |
| group: ${{ github.workflow }} | |
| cancel-in-progress: false | |
| jobs: | |
| check: | |
| if: github.event_name == 'workflow_dispatch' || github.ref_name == 'main' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_skip: ${{ steps.check.outputs.should_skip }} | |
| steps: | |
| - name: Check if tip release already exists for this commit | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| TIP_SHA=$(gh api repos/${{ github.repository }}/git/ref/tags/tip --jq '.object.sha' 2>/dev/null || echo "") | |
| ASSET_COUNT=$(gh release view tip -R "${{ github.repository }}" --json assets --jq '.assets | length' 2>/dev/null || echo "0") | |
| if [ "$TIP_SHA" = "${{ github.sha }}" ] && [ "$ASSET_COUNT" -gt 0 ]; then | |
| echo "Tip already points at ${{ github.sha }} with $ASSET_COUNT assets, skipping" | |
| echo "should_skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "should_skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| build: | |
| runs-on: macos-26 | |
| needs: [check] | |
| if: needs.check.outputs.should_skip != 'true' | |
| permissions: | |
| contents: read | |
| env: | |
| MISE_HTTP_TIMEOUT: 120 | |
| MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} | |
| DEVELOPER_ID_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_NOTARIZATION_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER }} | |
| APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} | |
| APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - uses: ./.github/actions/setup-macos | |
| - name: Override build number for tip | |
| run: | | |
| BASE=$(awk -F' = ' '/CURRENT_PROJECT_VERSION = [0-9]+/{gsub(/;/,""); print $2; exit}' supacode.xcodeproj/project.pbxproj) | |
| OFFSET=${{ github.run_number }} | |
| if [ "$OFFSET" -gt 999 ]; then | |
| echo "::error::Tip run_number ($OFFSET) exceeds 999. Bump CURRENT_PROJECT_VERSION before the next tip release." | |
| exit 1 | |
| fi | |
| BUILD_NUMBER=$((BASE * 1000 + OFFSET)) | |
| sed -i '' "s/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = $BUILD_NUMBER/g" supacode.xcodeproj/project.pbxproj | |
| echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" | |
| - name: Setup keychain | |
| run: | | |
| echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > build-cert.p12 | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain | |
| security set-keychain-settings -t 3600 -u build.keychain | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain | |
| security import build-cert.p12 -k build.keychain -P "$DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcodebuild > /dev/null | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null | |
| security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"') | |
| security default-keychain -s build.keychain | |
| DEVELOPER_ID_IDENTITY_SHA=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -1 | awk '{print $2}') | |
| if [ -z "$DEVELOPER_ID_IDENTITY_SHA" ]; then | |
| echo "::error::Developer ID Application identity not found in keychain" | |
| exit 1 | |
| fi | |
| echo "DEVELOPER_ID_IDENTITY_SHA=$DEVELOPER_ID_IDENTITY_SHA" >> "$GITHUB_ENV" | |
| - name: Inject secrets | |
| env: | |
| SENTRY_DSN: ${{ secrets.SENTRY_DSN }} | |
| POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} | |
| POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} | |
| run: | | |
| set -euo pipefail | |
| : "${SENTRY_DSN:?secret SENTRY_DSN is not set}" | |
| : "${POSTHOG_API_KEY:?secret POSTHOG_API_KEY is not set}" | |
| : "${POSTHOG_HOST:?secret POSTHOG_HOST is not set}" | |
| sed -i '' "s|__SENTRY_DSN__|${SENTRY_DSN}|g" supacode/App/supacodeApp.swift | |
| sed -i '' "s|__POSTHOG_API_KEY__|${POSTHOG_API_KEY}|g" supacode/App/supacodeApp.swift | |
| sed -i '' "s|__POSTHOG_HOST__|${POSTHOG_HOST}|g" supacode/App/supacodeApp.swift | |
| - name: Archive Xcode project | |
| run: make archive | |
| - name: Upload dSYMs | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: dsyms | |
| path: build/supacode.xcarchive/dSYMs | |
| - run: | | |
| cat > build/ExportOptions.plist <<EOF | |
| <?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> | |
| <key>method</key> | |
| <string>developer-id</string> | |
| <key>signingStyle</key> | |
| <string>manual</string> | |
| <key>signingCertificate</key> | |
| <string>$DEVELOPER_ID_IDENTITY</string> | |
| <key>teamID</key> | |
| <string>$APPLE_TEAM_ID</string> | |
| </dict> | |
| </plist> | |
| EOF | |
| make export-archive | |
| - name: Re-sign frameworks | |
| run: | | |
| set -ex | |
| APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" | |
| bash ./.github/scripts/resign_exported_app.sh build/export | |
| codesign -d --entitlements - "$APP_PATH/Contents/MacOS/supacode" 2>&1 | tee /tmp/supacode-entitlements.txt | |
| grep -q "com.apple.security.device.audio-input" /tmp/supacode-entitlements.txt | |
| - name: Store notarization credentials | |
| run: | | |
| echo "$APPLE_NOTARIZATION_KEY" > notarization_key.p8 | |
| xcrun notarytool store-credentials "notarytool-profile" \ | |
| --key notarization_key.p8 \ | |
| --key-id "$APPLE_NOTARIZATION_KEY_ID" \ | |
| --issuer "$APPLE_NOTARIZATION_ISSUER" | |
| rm notarization_key.p8 | |
| - name: Build DMG | |
| run: | | |
| APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" | |
| mise exec -- create-dmg "$APP_PATH" build/ \ | |
| --overwrite \ | |
| --dmg-title="Supacode" \ | |
| --identity="$DEVELOPER_ID_IDENTITY_SHA" | |
| DMG_OUTPUT=$(find build -name "*.dmg" -maxdepth 1 | head -1) | |
| if [ "$DMG_OUTPUT" != "build/supacode.dmg" ]; then | |
| mv "$DMG_OUTPUT" build/supacode.dmg | |
| fi | |
| - name: Notarize and staple | |
| run: | | |
| APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" | |
| for attempt in 1 2 3; do | |
| echo "Notarization attempt $attempt..." | |
| xcrun notarytool submit build/supacode.dmg --keychain-profile "notarytool-profile" --wait && break | |
| echo "Attempt $attempt failed, retrying in 30s..." | |
| sleep 30 | |
| done | |
| xcrun stapler staple build/supacode.dmg | |
| xcrun stapler staple "$APP_PATH" | |
| - name: Create app zip for Sparkle | |
| run: | | |
| APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" | |
| ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" build/supacode.app.zip | |
| - name: Generate tip appcast | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| MAX_DELTAS=10 | |
| STAGING=$(mktemp -d) | |
| cp build/supacode.app.zip "$STAGING/" | |
| # Download previous tip builds stored as history assets | |
| ASSETS=$(gh release view tip -R "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true) | |
| for asset in $ASSETS; do | |
| case "$asset" in | |
| supacode-history-*.app.zip) | |
| gh release download tip -p "$asset" -O "$STAGING/$asset" -R "${{ github.repository }}" 2>/dev/null || true | |
| ;; | |
| esac | |
| done | |
| printf "%s" "$SPARKLE_PRIVATE_KEY" | tr -d '\r\n\t ' | ./bins/generate_appcast \ | |
| --download-url-prefix "https://supacode.sh/download/tip/" \ | |
| --maximum-versions $((MAX_DELTAS + 1)) \ | |
| --maximum-deltas $MAX_DELTAS \ | |
| --delta-compression lzma \ | |
| --ed-key-file - "$STAGING" | |
| # Inject sparkle:channel tag into the tip items | |
| sed -i '' 's|<enclosure |<sparkle:channel>tip</sparkle:channel>\n <enclosure |' "$STAGING/appcast.xml" | |
| cp "$STAGING/appcast.xml" build/appcast.xml | |
| find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true | |
| - uses: actions/upload-artifact@v6 | |
| with: | |
| name: build-artifacts | |
| path: | | |
| build/supacode.dmg | |
| build/supacode.app.zip | |
| build/appcast.xml | |
| build/*.delta | |
| tag: | |
| runs-on: ubuntu-latest | |
| needs: [check, build] | |
| if: needs.check.outputs.should_skip != 'true' | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| token: ${{ secrets.GH_RELEASE_TOKEN }} | |
| - name: Force-move tip tag | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -fa tip -m "Latest Continuous Release" ${{ github.sha }} | |
| git push --force origin tip | |
| sentry-dsym: | |
| runs-on: ubuntu-latest | |
| needs: [check, build] | |
| if: needs.check.outputs.should_skip != 'true' | |
| steps: | |
| - name: Install sentry-cli | |
| run: curl -sL https://sentry.io/get-cli/ | sh | |
| - uses: actions/download-artifact@v7 | |
| with: | |
| name: dsyms | |
| path: dsyms | |
| - name: Upload dSYMs to Sentry | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: supabit | |
| SENTRY_PROJECT: supacode | |
| run: sentry-cli debug-files upload --include-sources dsyms | |
| publish: | |
| runs-on: ubuntu-latest | |
| needs: [check, build, tag] | |
| if: needs.check.outputs.should_skip != 'true' | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/download-artifact@v7 | |
| with: | |
| name: build-artifacts | |
| path: build | |
| - name: Update tip release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh release create tip --prerelease --title 'Supacode Tip ("Nightly")' --target "${{ github.sha }}" --notes "" -R "${{ github.repository }}" 2>/dev/null || | |
| gh release edit tip --prerelease --title 'Supacode Tip ("Nightly")' --target "${{ github.sha }}" -R "${{ github.repository }}" | |
| # Delete stale delta files from previous tip | |
| STALE_DELTAS=$(gh release view tip -R "${{ github.repository }}" --json assets --jq '.assets[].name | select(endswith(".delta"))' 2>/dev/null || true) | |
| for d in $STALE_DELTAS; do | |
| gh release delete-asset tip "$d" -R "${{ github.repository }}" -y 2>/dev/null || true | |
| done | |
| DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) | |
| gh release upload tip build/supacode.dmg build/supacode.app.zip build/appcast.xml $DELTA_FILES --clobber -R "${{ github.repository }}" | |
| # Store current build as history asset for future delta generation | |
| MAX_DELTAS=10 | |
| cp build/supacode.app.zip "build/supacode-history-${{ github.run_number }}.app.zip" | |
| gh release upload tip "build/supacode-history-${{ github.run_number }}.app.zip" --clobber -R "${{ github.repository }}" | |
| # Prune oldest history assets beyond MAX_DELTAS | |
| HISTORY=$(gh release view tip -R "${{ github.repository }}" --json assets --jq '[.assets[].name | select(startswith("supacode-history-"))] | sort | reverse' 2>/dev/null || echo "[]") | |
| PRUNE=$(echo "$HISTORY" | jq -r ".[$MAX_DELTAS:][]" 2>/dev/null || true) | |
| for old in $PRUNE; do | |
| gh release delete-asset tip "$old" -R "${{ github.repository }}" -y 2>/dev/null || true | |
| done | |
| - name: Merge tip item into production appcast | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| LATEST_TAG=$(gh release list --exclude-drafts --exclude-pre-releases -R "${{ github.repository }}" --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") | |
| if [ -z "$LATEST_TAG" ]; then | |
| echo "No stable release found, skipping appcast merge" | |
| exit 0 | |
| fi | |
| gh release download "$LATEST_TAG" -p "appcast.xml" -O stable_appcast.xml -R "${{ github.repository }}" 2>/dev/null || exit 0 | |
| python3 - stable_appcast.xml build/appcast.xml merged_appcast.xml <<'PYEOF' | |
| import sys | |
| import xml.etree.ElementTree as ET | |
| ET.register_namespace('sparkle', 'http://www.andymatuschak.org/xml-namespaces/sparkle') | |
| ET.register_namespace('dc', 'http://purl.org/dc/elements/1.1/') | |
| stable_path, tip_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3] | |
| stable_tree = ET.parse(stable_path) | |
| stable_root = stable_tree.getroot() | |
| tip_tree = ET.parse(tip_path) | |
| tip_root = tip_tree.getroot() | |
| ns = {'sparkle': 'http://www.andymatuschak.org/xml-namespaces/sparkle'} | |
| stable_channel = stable_root.find('.//channel') | |
| # Remove existing tip items from stable appcast | |
| for item in stable_channel.findall('item'): | |
| ch = item.find('sparkle:channel', ns) | |
| if ch is not None and ch.text == 'tip': | |
| stable_channel.remove(item) | |
| # Add new tip items from tip appcast | |
| tip_channel = tip_root.find('.//channel') | |
| for item in tip_channel.findall('item'): | |
| stable_channel.append(item) | |
| stable_tree.write(out_path, xml_declaration=True, encoding='utf-8') | |
| PYEOF | |
| mv merged_appcast.xml appcast.xml | |
| gh release upload "$LATEST_TAG" appcast.xml --clobber -R "${{ github.repository }}" |