Release #42
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 | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Tag to release (e.g. v0.1.0)" | |
| required: true | |
| permissions: | |
| contents: write | |
| jobs: | |
| check-signing: | |
| name: Check signing secrets | |
| runs-on: ubuntu-latest | |
| outputs: | |
| enabled: ${{ steps.check.outputs.enabled }} | |
| steps: | |
| - name: Verify DEVELOPER_ID_CERT_P12 is configured | |
| id: check | |
| env: | |
| DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| run: | | |
| if [[ -z "${DEVELOPER_ID_CERT_P12}" ]]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Signing secrets not yet configured — skipping binary release build. Configure them in repo Settings → Secrets and variables → Actions when you have a Developer ID certificate." | |
| else | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| release: | |
| name: Build, Sign & Release (arm64) | |
| needs: check-signing | |
| if: needs.check-signing.outputs.enabled == 'true' | |
| runs-on: macos-26 | |
| # arm64-only: Bòcan targets Apple Silicon (macOS 15+). Intel Macs are not | |
| # supported because the bundled FFmpeg dylibs and fpcalc binary in Resources/ | |
| # are arm64-only. A universal binary would require rebuilding those as fat | |
| # binaries, which is non-trivial. The app will simply refuse to launch on | |
| # x86_64 hardware. | |
| env: | |
| ARCHS: arm64 | |
| ONLY_ACTIVE_ARCH: "NO" | |
| MARKETING_VERSION: ${{ github.event.inputs.tag || github.ref_name }} | |
| HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY != '' && 'true' || 'false' }} | |
| steps: | |
| - name: Checkout (full history for changelog) | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Select Xcode 26 | |
| run: sudo xcode-select -s /Applications/Xcode_26.5.app | |
| - name: Cache Homebrew packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: ~/Library/Caches/Homebrew | |
| key: ${{ runner.os }}-brew-${{ hashFiles('Brewfile.lock.json') }} | |
| restore-keys: | | |
| ${{ runner.os }}-brew- | |
| - name: Cache SPM packages | |
| uses: actions/cache@v5 | |
| with: | |
| path: | | |
| ~/Library/Developer/Xcode/DerivedData/**/SourcePackages | |
| ~/.swiftpm | |
| key: ${{ runner.os }}-spm-release-${{ hashFiles('**/Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm-release- | |
| ${{ runner.os }}-spm- | |
| - name: Install Brewfile dependencies | |
| run: brew bundle install --no-upgrade | |
| - name: Set pkg-config path for FFmpeg and TagLib | |
| run: | | |
| echo "PKG_CONFIG_PATH=/opt/homebrew/opt/ffmpeg/lib/pkgconfig:/opt/homebrew/opt/taglib/lib/pkgconfig" >> $GITHUB_ENV | |
| - name: Stub Secrets.xcconfig (xcodegen requires it) | |
| env: | |
| ACOUSTID_API_KEY: ${{ secrets.ACOUSTID_API_KEY }} | |
| BOCAN_LASTFM_API_KEY: ${{ secrets.BOCAN_LASTFM_API_KEY }} | |
| BOCAN_LASTFM_SHARED_SECRET: ${{ secrets.BOCAN_LASTFM_SHARED_SECRET }} | |
| run: | | |
| if [[ ! -f Secrets.xcconfig ]]; then | |
| cp Secrets.xcconfig.template Secrets.xcconfig | |
| fi | |
| if [[ -n "${ACOUSTID_API_KEY:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^ACOUSTID_API_KEY.*|ACOUSTID_API_KEY = ${ACOUSTID_API_KEY}|" Secrets.xcconfig | |
| fi | |
| if [[ -n "${BOCAN_LASTFM_API_KEY:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^BOCAN_LASTFM_API_KEY.*|BOCAN_LASTFM_API_KEY = ${BOCAN_LASTFM_API_KEY}|" Secrets.xcconfig | |
| fi | |
| if [[ -n "${BOCAN_LASTFM_SHARED_SECRET:-}" ]]; then | |
| /usr/bin/sed -i '' "s|^BOCAN_LASTFM_SHARED_SECRET.*|BOCAN_LASTFM_SHARED_SECRET = ${BOCAN_LASTFM_SHARED_SECRET}|" Secrets.xcconfig | |
| fi | |
| - name: Generate Xcode project | |
| run: make generate | |
| - name: Run tests before release (fail fast) | |
| run: make test-coverage 2>&1 | xcbeautify | |
| - name: Import Developer ID certificate | |
| env: | |
| DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} | |
| DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} | |
| run: | | |
| KEYCHAIN_PATH="$RUNNER_TEMP/bocan.keychain-db" | |
| KEYCHAIN_PASSWORD=$(openssl rand -base64 32) | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$DEVELOPER_ID_CERT_PASSWORD" \ | |
| -T /usr/bin/codesign | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | xargs) | |
| security set-key-partition-list \ | |
| -S apple-tool:,apple: \ | |
| -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| - name: Bundle fpcalc + FFmpeg dylibs into Resources/ | |
| env: | |
| SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} | |
| run: make bundle-fpcalc | |
| - name: Stamp version into Info.plist | |
| run: | | |
| VERSION="${MARKETING_VERSION#v}" | |
| BUILD_NUMBER=$(date +%s) | |
| # CFBundleShortVersionString: stamp the marketing version string directly. | |
| # release-please also updates this, but re-stamping here is safe and ensures | |
| # the archive always matches the tag even on workflow_dispatch runs. | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" Resources/Info.plist | |
| # CFBundleVersion is NOT stamped here — Info.plist uses $(CURRENT_PROJECT_VERSION) | |
| # which xcodebuild resolves from the build setting we pass on the archive command line. | |
| echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" | |
| echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" | |
| - name: Archive (arm64 only) | |
| run: | | |
| set -o pipefail | |
| xcodebuild archive \ | |
| -project Bocan.xcodeproj \ | |
| -scheme Bocan \ | |
| -configuration Release \ | |
| -destination 'generic/platform=macOS' \ | |
| -archivePath build/Bocan.xcarchive \ | |
| ARCHS=arm64 \ | |
| ONLY_ACTIVE_ARCH=NO \ | |
| CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ | |
| MARKETING_VERSION="$RELEASE_VERSION" \ | |
| CODE_SIGN_STYLE=Manual \ | |
| DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \ | |
| CODE_SIGN_IDENTITY="${{ secrets.DEVELOPER_ID_IDENTITY }}" \ | |
| OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ | |
| ENABLE_HARDENED_RUNTIME=YES \ | |
| | xcbeautify | |
| - name: Export (Developer ID) | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| # Generate ExportOptions with the real team ID ($(VAR) syntax is not | |
| # expanded by xcodebuild -exportArchive, so we substitute it here). | |
| sed "s/\$(APPLE_TEAM_ID)/${APPLE_TEAM_ID}/g" Scripts/ExportOptions.plist \ | |
| > /tmp/ExportOptions.plist | |
| set -o pipefail | |
| xcodebuild -exportArchive \ | |
| -archivePath build/Bocan.xcarchive \ | |
| -exportOptionsPlist /tmp/ExportOptions.plist \ | |
| -exportPath build/export \ | |
| | xcbeautify | |
| echo "=== Export contents ===" | |
| ls -la build/export/ | |
| - name: Bundle Homebrew dylibs into app (TagLib, FFmpeg, etc.) | |
| env: | |
| SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} | |
| run: bash Scripts/embed-deps.sh build/export/Bocan.app | |
| - name: Verify code signature & hardened runtime | |
| run: | | |
| APP="build/export/Bocan.app" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| codesign --display --verbose=4 "$APP" 2>&1 | tee /tmp/codesign.txt | |
| if ! grep -q "flags=.*runtime" /tmp/codesign.txt; then | |
| echo "::error::Hardened runtime flag missing from signature." | |
| exit 1 | |
| fi | |
| - name: Notarize app | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| # notarytool requires a zip for .app submission. | |
| ditto -c -k --keepParent "build/export/Bocan.app" "build/Bocan-notarize.zip" | |
| RESULT=$(xcrun notarytool submit "build/Bocan-notarize.zip" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" \ | |
| --wait --output-format plist) | |
| echo "$RESULT" | |
| SUBMISSION_ID=$(/usr/libexec/PlistBuddy -c "Print :id" /dev/stdin <<< "$RESULT") | |
| STATUS=$(/usr/libexec/PlistBuddy -c "Print :status" /dev/stdin <<< "$RESULT") | |
| if [[ "$STATUS" != "Accepted" ]]; then | |
| echo "::error::Notarization was not accepted (status: $STATUS). Fetching rejection log..." | |
| xcrun notarytool log "$SUBMISSION_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" || true | |
| exit 1 | |
| fi | |
| - name: Staple app | |
| # Apple's CloudKit CDN can take a moment to propagate the ticket | |
| # even after notarytool --wait returns. Retry up to 5 times. | |
| run: | | |
| for attempt in $(seq 1 5); do | |
| xcrun stapler staple build/export/Bocan.app && break | |
| if [[ $attempt -lt 5 ]]; then | |
| echo "Staple attempt $attempt failed; waiting 30 s before retry..." | |
| sleep 30 | |
| else | |
| echo "::error::xcrun stapler failed after $attempt attempts." | |
| exit 1 | |
| fi | |
| done | |
| - name: Create DMG | |
| run: | | |
| create-dmg \ | |
| --volname "Bòcan ${RELEASE_VERSION}" \ | |
| --background "Resources/Distribution/dmg-background.png" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 128 \ | |
| --icon "Bocan.app" 140 195 \ | |
| --hide-extension "Bocan.app" \ | |
| --app-drop-link 400 195 \ | |
| --volicon "Resources/Distribution/VolumeIcon.icns" \ | |
| "build/Bocan.dmg" \ | |
| "build/export/" \ | |
| || (sleep 5 && create-dmg \ | |
| --volname "Bòcan ${RELEASE_VERSION}" \ | |
| --background "Resources/Distribution/dmg-background.png" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 128 \ | |
| --icon "Bocan.app" 140 195 \ | |
| --hide-extension "Bocan.app" \ | |
| --app-drop-link 400 195 \ | |
| --volicon "Resources/Distribution/VolumeIcon.icns" \ | |
| "build/Bocan.dmg" \ | |
| "build/export/") | |
| - name: Sign DMG | |
| run: | | |
| codesign --force --sign "${{ secrets.DEVELOPER_ID_IDENTITY }}" \ | |
| --timestamp build/Bocan.dmg | |
| - name: Notarize DMG | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| xcrun notarytool submit "build/Bocan.dmg" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APP_SPECIFIC_PASSWORD" \ | |
| --wait | |
| xcrun stapler staple build/Bocan.dmg | |
| - name: Gatekeeper assessment | |
| run: spctl --assess --type open --context context:primary-signature --verbose=4 build/Bocan.dmg | |
| - name: Compute checksums | |
| id: checksums | |
| run: | | |
| cd build | |
| shasum -a 256 Bocan.dmg | tee Bocan.dmg.sha256 | |
| SHA256=$(awk '{print $1}' Bocan.dmg.sha256) | |
| echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" | |
| - name: Generate release notes from CHANGELOG | |
| id: notes | |
| run: | | |
| chmod +x Scripts/release-notes.sh | |
| NOTES_FILE=$(mktemp) | |
| Scripts/release-notes.sh "${MARKETING_VERSION#v}" > "$NOTES_FILE" || \ | |
| echo "_See commit history for changes._" > "$NOTES_FILE" | |
| { | |
| echo "notes_path=$NOTES_FILE" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Sign appcast entry (Sparkle EdDSA) | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| chmod +x Scripts/sparkle-update.sh | |
| Scripts/sparkle-update.sh \ | |
| --dmg build/Bocan.dmg \ | |
| --version "${RELEASE_VERSION}" \ | |
| --build "${BUILD_NUMBER}" \ | |
| --output build/appcast-entry.xml | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| name: ${{ env.RELEASE_VERSION }} | |
| tag_name: ${{ github.event.inputs.tag || github.ref_name }} | |
| body_path: ${{ steps.notes.outputs.notes_path }} | |
| files: | | |
| build/Bocan.dmg | |
| build/Bocan.dmg.sha256 | |
| build/appcast-entry.xml | |
| fail_on_unmatched_files: false | |
| draft: false | |
| prerelease: ${{ contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} | |
| - name: Update appcast.xml | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| env: | |
| SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} | |
| run: | | |
| FEED="website/static/appcast.xml" | |
| if [[ "$GITHUB_REF_NAME" == *"-beta"* || "$GITHUB_REF_NAME" == *"-rc"* ]]; then | |
| FEED="website/static/appcast-beta.xml" | |
| fi | |
| chmod +x Scripts/sparkle-update.sh | |
| Scripts/sparkle-update.sh \ | |
| --dmg build/Bocan.dmg \ | |
| --version "${RELEASE_VERSION}" \ | |
| --build "${BUILD_NUMBER}" \ | |
| --prepend-to "$FEED" | |
| echo "APPCAST_FEED=$FEED" >> "$GITHUB_ENV" | |
| - name: Commit and push updated appcast | |
| if: env.HAS_SPARKLE_KEY == 'true' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add website/static/appcast.xml website/static/appcast-beta.xml | |
| git diff --cached --quiet && echo "No appcast changes to commit" && exit 0 | |
| git commit -m "chore(release): update appcast for v${RELEASE_VERSION}" | |
| git push origin HEAD:main | |
| - name: Trigger cask update | |
| uses: peter-evans/repository-dispatch@v4 | |
| with: | |
| token: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| repository: bocan/homebrew-bocan | |
| event-type: new-release | |
| client-payload: | | |
| { | |
| "version": "${{ env.RELEASE_VERSION }}", | |
| "sha256": "${{ steps.checksums.outputs.sha256 }}" | |
| } |