Skip to content

Release

Release #28

Workflow file for this run

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 macOS 26+, which runs exclusively on Apple Silicon.
# Intel Macs cannot run macOS 26, so there is no x86_64 user base to support.
# A universal binary would double CI build time with no benefit. FFmpeg dylibs
# and fpcalc in Resources/ are arm64-only as well. Revisit if the deployment
# target is ever lowered to macOS 14 or 15.
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.4.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 }}"
}