Skip to content

Nightly Build

Nightly Build #152

Workflow file for this run

name: Nightly Build
on:
schedule:
# Run every day at midnight UTC
- cron: '0 0 * * *'
workflow_dispatch:
# Allow manual trigger for testing
inputs:
skip_notarize:
description: 'Skip notarization (faster builds for debugging)'
required: false
default: false
type: boolean
permissions:
contents: write
concurrency:
group: nightly-build
cancel-in-progress: true
jobs:
check-for-changes:
name: Check for New Commits
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
latest_tag: ${{ steps.check.outputs.latest_tag }}
changelog: ${{ steps.changelog.outputs.changelog }}
commit_count: ${{ steps.check.outputs.commit_count }}
short_sha: ${{ steps.check.outputs.short_sha }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Check for changes since last tag
id: check
run: |
# Find the latest stable release tag (vX.Y.Z format)
LATEST_TAG=$(git tag --sort=-version:refname --list 'v*' | head -1)
echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"
echo "📌 Latest stable tag: ${LATEST_TAG}"
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
if [ -z "$LATEST_TAG" ]; then
echo "No tags found — treating all commits as new"
COMMIT_COUNT=$(git rev-list --count HEAD)
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "commit_count=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
else
# Count commits since last tag
COMMIT_COUNT=$(git rev-list --count "${LATEST_TAG}..HEAD")
echo "commit_count=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo "✅ No new commits since ${LATEST_TAG} — skipping nightly build"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "🔄 Found ${COMMIT_COUNT} new commit(s) since ${LATEST_TAG}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
fi
- name: Generate changelog
id: changelog
if: steps.check.outputs.has_changes == 'true'
run: |
LATEST_TAG="${{ steps.check.outputs.latest_tag }}"
# Build changelog from commits since last tag
if [ -z "$LATEST_TAG" ]; then
RANGE="HEAD"
else
RANGE="${LATEST_TAG}..HEAD"
fi
# Generate categorized changelog
{
echo "changelog<<CHANGELOG_EOF"
# Features
FEATS=$(git log "$RANGE" --pretty=format:"- %s (%h)" --grep="^feat" --no-merges 2>/dev/null || true)
if [ -n "$FEATS" ]; then
echo "### ✨ New Features"
echo "$FEATS"
echo ""
fi
# Fixes
FIXES=$(git log "$RANGE" --pretty=format:"- %s (%h)" --grep="^fix" --no-merges 2>/dev/null || true)
if [ -n "$FIXES" ]; then
echo "### 🐛 Bug Fixes"
echo "$FIXES"
echo ""
fi
# UI changes
UI=$(git log "$RANGE" --pretty=format:"- %s (%h)" --grep="^ui" --no-merges 2>/dev/null || true)
if [ -n "$UI" ]; then
echo "### 🎨 UI Improvements"
echo "$UI"
echo ""
fi
# CI/Chore/Docs
OTHER=$(git log "$RANGE" --pretty=format:"- %s (%h)" --grep="^chore\|^ci\|^docs\|^refactor\|^perf\|^test" --no-merges 2>/dev/null || true)
if [ -n "$OTHER" ]; then
echo "### 🔧 Maintenance & Other"
echo "$OTHER"
echo ""
fi
# Uncategorized (commits not matching conventional commit prefixes)
ALL_CATEGORIZED=$(git log "$RANGE" --pretty=format:"%h" --grep="^feat\|^fix\|^ui\|^chore\|^ci\|^docs\|^refactor\|^perf\|^test" --no-merges 2>/dev/null | sort || true)
ALL_COMMITS=$(git log "$RANGE" --pretty=format:"%h" --no-merges 2>/dev/null | sort || true)
UNCATEGORIZED_HASHES=$(comm -23 <(echo "$ALL_COMMITS") <(echo "$ALL_CATEGORIZED") 2>/dev/null || true)
if [ -n "$UNCATEGORIZED_HASHES" ]; then
UNCATEGORIZED=""
while IFS= read -r hash; do
[ -z "$hash" ] && continue
MSG=$(git log -1 --pretty=format:"- %s (%h)" "$hash" 2>/dev/null || true)
UNCATEGORIZED="${UNCATEGORIZED}${MSG}"$'\n'
done <<< "$UNCATEGORIZED_HASHES"
if [ -n "$(echo "$UNCATEGORIZED" | tr -d '[:space:]')" ]; then
echo "### 📝 Other Changes"
echo "$UNCATEGORIZED"
echo ""
fi
fi
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
build-nightly:
name: Build Nightly
needs: check-for-changes
if: needs.check-for-changes.outputs.has_changes == 'true'
runs-on: macos-15
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Cache SPM dependencies
uses: actions/cache@v5
with:
path: .build
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-
- name: Select Xcode 26
run: ./scripts/select-xcode-26.sh
- name: Run tests
run: swift test
- name: Initialize Xcode tools
run: sudo xcodebuild -runFirstLaunch
# ── Code Signing Setup ──────────────────────────────────────────────────
- 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: |
# Create a temporary keychain so the cert is isolated to this job
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"
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 the p12
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 \
-T /usr/bin/security
# Allow codesign to access the key without a passphrase prompt
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" \
"$KEYCHAIN_PATH"
# Add to keychain search list
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | xargs)
# Store the keychain path for later steps
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
# Verify the identity
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)"/\1/')
if [ -z "$IDENTITY" ]; then
echo "❌ Developer ID certificate not found after import"
exit 1
fi
echo "✅ Imported: $IDENTITY"
echo "CODE_SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
- name: Store notarization credentials
if: ${{ !inputs.skip_notarize }}
env:
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
NOTARIZE_TEAM_ID: ${{ secrets.NOTARIZE_TEAM_ID }}
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
run: |
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id "$NOTARIZE_APPLE_ID" \
--team-id "$NOTARIZE_TEAM_ID" \
--password "$NOTARIZE_PASSWORD"
# ── Nightly Version ────────────────────────────────────────────────────
- name: Set nightly version
run: |
NIGHTLY_DATE=$(date -u +%Y%m%d)
SHORT_SHA="${{ needs.check-for-changes.outputs.short_sha }}"
# Extract the default version from APP_VERSION="${APP_VERSION:-X.Y.Z}" in build.sh
BASE_VERSION=$(grep '^APP_VERSION=' scripts/build.sh | sed 's/.*:-\(.*\)}.*/\1/')
if [ -z "$BASE_VERSION" ]; then
BASE_VERSION="0.5.0"
fi
APP_VERSION="${BASE_VERSION}-nightly.${NIGHTLY_DATE}+${SHORT_SHA}"
echo "APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV"
echo "📌 Nightly version: ${APP_VERSION}"
# ── Bundled Tiny Model ─────────────────────────────────────────────────
- name: Set up Python for huggingface-cli
run: |
python3 -m venv "$RUNNER_TEMP/hf-venv"
source "$RUNNER_TEMP/hf-venv/bin/activate"
pip install -U huggingface_hub
echo "$RUNNER_TEMP/hf-venv/bin" >> "$GITHUB_PATH"
- name: Fetch bundled tiny model from Hugging Face
run: |
MODEL_STAGE="$RUNNER_TEMP/whisperkit-coreml"
hf download argmaxinc/whisperkit-coreml \
--include "openai_whisper-tiny/*" \
--local-dir "$MODEL_STAGE"
hf download openai/whisper-tiny \
tokenizer.json tokenizer_config.json \
--local-dir "$MODEL_STAGE/openai_whisper-tiny"
rm -rf "$MODEL_STAGE/.cache" "$MODEL_STAGE/openai_whisper-tiny/.cache"
test -d "$MODEL_STAGE/openai_whisper-tiny"
test -f "$MODEL_STAGE/openai_whisper-tiny/tokenizer.json"
test -f "$MODEL_STAGE/openai_whisper-tiny/tokenizer_config.json"
test -d "$MODEL_STAGE/openai_whisper-tiny/MelSpectrogram.mlmodelc"
test -d "$MODEL_STAGE/openai_whisper-tiny/AudioEncoder.mlmodelc"
test -d "$MODEL_STAGE/openai_whisper-tiny/TextDecoder.mlmodelc"
echo "VOCAMAC_BUNDLED_MODEL_SOURCE=$MODEL_STAGE" >> "$GITHUB_ENV"
# ── Build & Package ─────────────────────────────────────────────────────
- name: Build signed, notarized DMG via dist.sh
run: |
DIST_FLAGS=""
if [ "${{ inputs.skip_notarize }}" = "true" ]; then
DIST_FLAGS="--skip-notarize"
fi
./scripts/dist.sh $DIST_FLAGS
env:
APP_VERSION: ${{ env.APP_VERSION }}
VOCAMAC_BUNDLED_MODEL_SOURCE: ${{ env.VOCAMAC_BUNDLED_MODEL_SOURCE }}
- name: Verify code signature
run: codesign -v --deep --strict VocaMac.app
- name: Verify SPM resource bundles are correctly staged
run: |
echo "🔍 Checking SPM resource bundle layout..."
FAIL=0
# xcodebuild's Bundle.module accessor checks Bundle.main.resourceURL
# which resolves to Contents/Resources/ for .app bundles.
# Bundles must NOT be at the .app root (codesign rejects that).
if ls VocaMac.app/*.bundle 1>/dev/null 2>&1; then
echo "❌ Found .bundle at app root — will break codesign!"
ls VocaMac.app/*.bundle
FAIL=1
fi
FOUND_ANY=false
for bundle in VocaMac.app/Contents/Resources/*.bundle; do
[ -d "$bundle" ] || continue
FOUND_ANY=true
name="$(basename "$bundle")"
echo "✅ $name — present in Contents/Resources/"
done
if [ "$FOUND_ANY" = false ]; then
echo "❌ No .bundle directories found in Contents/Resources/!"
FAIL=1
fi
# Verify the critical WhisperKit tokenizer fallback resources.
# xcodebuild bundles have the structure: <bundle>/Contents/Resources/<file>
HUB="VocaMac.app/Contents/Resources/swift-transformers_Hub.bundle"
if [ ! -d "$HUB" ]; then
echo "❌ swift-transformers_Hub.bundle missing!"
FAIL=1
else
for f in gpt2_tokenizer_config.json t5_tokenizer_config.json; do
# Search recursively — xcodebuild places resources in Contents/Resources/
if find "$HUB" -name "$f" -print -quit | grep -q .; then
echo "✅ $f present in swift-transformers_Hub.bundle"
else
echo "❌ Missing $f in swift-transformers_Hub.bundle"
FAIL=1
fi
done
fi
# Verify the binary uses the xcodebuild accessor (contains resourceURL)
if strings VocaMac.app/Contents/MacOS/VocaMac | grep -q 'resourceURL'; then
echo "✅ Binary uses xcodebuild Bundle.module accessor"
else
echo "❌ Binary does NOT contain resourceURL — wrong accessor!"
echo " Ensure build.sh uses xcodebuild, not swift build."
FAIL=1
fi
if [ "$FAIL" -ne 0 ]; then
echo ""
echo "Bundle layout validation failed. See above for details."
exit 1
fi
echo "✅ All SPM resource bundles are correctly staged."
- name: Rename artifacts for nightly
run: |
NIGHTLY_DATE=$(date -u +%Y%m%d)
SHORT_SHA="${{ needs.check-for-changes.outputs.short_sha }}"
ARCH=$(uname -m)
# Rename the DMG from dist/ to nightly naming convention
DIST_DMG=$(ls dist/VocaMac-*.dmg | head -1)
DMG_NAME="VocaMac-nightly-${NIGHTLY_DATE}-${SHORT_SHA}-${ARCH}.dmg"
cp "$DIST_DMG" "$DMG_NAME"
echo "DMG_NAME=${DMG_NAME}" >> "$GITHUB_ENV"
# Create ZIP from the signed .app
ZIP_NAME="VocaMac-nightly-${NIGHTLY_DATE}-${SHORT_SHA}-${ARCH}.zip"
ditto -c -k --sequesterRsrc --keepParent VocaMac.app "$ZIP_NAME"
echo "ZIP_NAME=${ZIP_NAME}" >> "$GITHUB_ENV"
# Create stable-named copies for Homebrew cask (no date/SHA)
cp "$DMG_NAME" "VocaMac-nightly-${ARCH}.dmg"
cp "$ZIP_NAME" "VocaMac-nightly-${ARCH}.zip"
echo "Artifacts created:"
ls -lh "$DMG_NAME" "$ZIP_NAME" "VocaMac-nightly-${ARCH}.dmg" "VocaMac-nightly-${ARCH}.zip"
- name: Generate checksums
run: |
shasum -a 256 VocaMac-nightly-*.dmg VocaMac-nightly-*.zip > checksums.txt
cat checksums.txt
# ── Cleanup ─────────────────────────────────────────────────────────────
- name: Delete temporary keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
# ── Release ─────────────────────────────────────────────────────────────
- name: Delete existing nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Delete the existing nightly release (if any) to keep only one active
echo "🗑️ Checking for existing nightly release..."
if gh release view nightly &>/dev/null; then
echo " Found existing nightly release — deleting..."
gh release delete nightly --yes --cleanup-tag
echo " ✅ Old nightly release deleted"
else
echo " No existing nightly release found"
fi
- name: Create nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHANGELOG: ${{ needs.check-for-changes.outputs.changelog }}
run: |
LATEST_TAG="${{ needs.check-for-changes.outputs.latest_tag }}"
COMMIT_COUNT="${{ needs.check-for-changes.outputs.commit_count }}"
SHORT_SHA="${{ needs.check-for-changes.outputs.short_sha }}"
NIGHTLY_DATE=$(date -u +%Y-%m-%d)
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
# Build the stable release link
if [ -n "$LATEST_TAG" ]; then
STABLE_LINK="[**${LATEST_TAG}**](${REPO_URL}/releases/tag/${LATEST_TAG})"
else
STABLE_LINK="_No stable release available yet._"
fi
# Determine signing status for release notes
if [ "${{ inputs.skip_notarize }}" = "true" ]; then
SIGNING_NOTE="> **Note:** This nightly build is **Developer ID signed** but not notarized."
SIGNING_NOTE="${SIGNING_NOTE}"$'\n'"> macOS may show a Gatekeeper warning on first open."
SIGNING_NOTE="${SIGNING_NOTE}"$'\n'"> To bypass: right-click VocaMac.app → Open → click Open in the dialog."
else
SIGNING_NOTE="> This nightly build is **Developer ID signed and notarized** by Apple."
SIGNING_NOTE="${SIGNING_NOTE}"$'\n'"> macOS will open it without any security warnings."
fi
# Write release notes to a file (avoids heredoc indentation issues)
{
echo "## ⚠️ Nightly Build — Not for Production Use"
echo ""
echo "> **This is an automated nightly build from the latest \`main\` branch.**"
echo "> It is intended for **developers and testers** who want to try the latest changes"
echo "> before they are included in a stable release."
echo ">"
echo "> **This build may be unstable, contain bugs, or include incomplete features.**"
echo "> Do not use it as your daily driver. If you encounter issues, please"
echo "> [report them](${REPO_URL}/issues/new) — your feedback helps us improve!"
echo ""
echo "### 🏷️ Latest Stable Release"
echo ""
echo "👉 For a reliable experience, use the latest stable release: ${STABLE_LINK}"
echo ""
echo "---"
echo ""
echo "### 📋 What's Changed Since ${LATEST_TAG:-the beginning}"
echo ""
echo "_${COMMIT_COUNT} commit(s) since the last stable release (${LATEST_TAG:-N/A}) • built from \`main\` @ [\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHORT_SHA})_"
echo ""
echo "$CHANGELOG"
echo ""
echo "---"
echo ""
echo "### 📥 Installation"
echo ""
echo "1. Download \`${DMG_NAME}\`"
echo "2. Open the DMG and drag VocaMac to Applications"
echo "3. Open VocaMac from Applications"
echo "4. Grant Microphone, Accessibility, and Input Monitoring permissions when prompted"
echo ""
echo "$SIGNING_NOTE"
echo ""
echo "### Checksums (SHA-256)"
echo "\`\`\`"
cat checksums.txt
echo "\`\`\`"
echo ""
echo "### Requirements"
echo "- macOS 14 (Sonoma) or later"
echo "- Apple Silicon (arm64)"
} > release-notes.md
gh release create nightly \
--title "🌙 Nightly Build — ${NIGHTLY_DATE}" \
--notes-file release-notes.md \
--prerelease \
--target main \
VocaMac-nightly-*.dmg \
VocaMac-nightly-*.zip \
checksums.txt
echo "✅ Nightly release created!"