Skip to content

chore(release): 1.0.2 #157

chore(release): 1.0.2

chore(release): 1.0.2 #157

Workflow file for this run

name: Release
# Manual release cycle (MAK-58). A release is cut ONLY when a version tag is
# pushed — merges to main no longer publish anything. Cut one with
# `scripts/release.sh [major|minor|patch]`, which bumps the version and pushes a
# tag of the form v<version>+<build> (e.g. v1.0.1+156):
# - <version> the human short version (CFBundleShortVersionString)
# - <build> the monotonic CFBundleVersion Sparkle orders updates by
# The workflow_dispatch button is a manual fallback (re-run a build for the tag
# currently checked out); the normal path is a tag push.
on:
push:
tags: ['v*']
workflow_dispatch: # manual fallback / re-run
permissions:
contents: write # needed to create releases + push tags
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: macos-14
steps:
- name: Checkout (with whisper.cpp submodule)
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
# The default runner Xcode (15.4 / Swift 5.10) rejects the app's modern
# concurrency; select the newest stable Xcode (Swift 6).
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: latest-stable
- name: Show toolchain
run: swift --version
- name: Run unit tests
run: swift test
# Version + build come from the TAG (v<version>+<build>), not the run
# number, so a manually-cut release is fully reproducible: re-running the
# workflow for the same tag rebuilds the same version + build. On a
# workflow_dispatch with no tag ref we fall back to Info.plist + run number
# so the manual button still produces a valid (if throwaway) build.
- name: Compute version
id: version
run: |
REF="${GITHUB_REF#refs/tags/}"
if [[ "$REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$ ]]; then
BASE="${BASH_REMATCH[1]}"
BUILD="${BASH_REMATCH[2]}"
TAG="v${BASE}+${BUILD}"
else
echo "::warning::Not a v<version>+<build> tag ('$GITHUB_REF') — falling back to Info.plist + run number (manual dispatch)."
BASE=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" OpenWhisp/Info.plist)
BUILD="${GITHUB_RUN_NUMBER}"
TAG="v${BASE}+${BUILD}"
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "build=$BUILD" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Release tag: $TAG (commit ${GITHUB_SHA:0:7})"
- name: Stamp version + build number into Info.plist
run: |
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${{ steps.version.outputs.base }}" OpenWhisp/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${{ steps.version.outputs.build }}" OpenWhisp/Info.plist
- name: Build whisper.cpp (submodule)
run: ./scripts/build-whisper.sh
# The built-in AI provider (bundled llama.cpp) is the app default, so the
# release DMG must include its runtime — build-dmg.sh release fails without it.
- name: Build llama.cpp (submodule)
run: ./scripts/build-llama.sh
# Signing is enabled only when the Developer ID secret is present. Forks and
# setups without the secret still get an ad-hoc DMG (unchanged behavior).
# Fail fast on a HALF-configured setup (cert without notary credentials, or
# missing passwords): a clear error here beats a confusing notarytool failure
# twenty minutes into the build.
- name: Detect signing availability
id: signing
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
run: |
if [ -z "$MACOS_CERT_P12_BASE64" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "No MACOS_CERT_P12_BASE64 secret — building ad-hoc (unsigned) DMG."
exit 0
fi
MISSING=""
for VAR in MACOS_CERT_PASSWORD KEYCHAIN_PASSWORD NOTARY_APPLE_ID NOTARY_TEAM_ID NOTARY_PASSWORD; do
[ -n "${!VAR:-}" ] || MISSING="$MISSING $VAR"
done
if [ -n "$MISSING" ]; then
echo "::error::MACOS_CERT_P12_BASE64 is set but these signing secrets are missing:$MISSING"
echo "::error::Set them all (see docs/SIGNING_AND_NOTARIZATION.md) or remove MACOS_CERT_P12_BASE64 to build ad-hoc."
exit 1
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"
- name: Import Developer ID certificate
if: steps.signing.outputs.enabled == 'true'
id: cert
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# The identity name is NOT masked: it's embedded in the signature of
# every published DMG (anyone can read it with `codesign -dv`), and
# masking it would make the runner drop the step output ("Skip output
# since it may contain secret"), leaving SIGN_IDENTITY empty and
# failing the signed build.
IDENTITY="$(./scripts/ci-import-cert.sh)"
echo "Imported identity: $IDENTITY"
echo "identity=$IDENTITY" >> "$GITHUB_OUTPUT"
- name: Build app + DMG (signed + notarized)
if: steps.signing.outputs.enabled == 'true'
env:
NOTARIZE: "1"
SIGN_IDENTITY: ${{ steps.cert.outputs.identity }}
NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }}
NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }}
NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }}
run: ./build-dmg.sh release
- name: Build app + DMG (ad-hoc, unsigned)
if: steps.signing.outputs.enabled != 'true'
run: ./build-dmg.sh release
- name: Verify DMG exists
run: |
test -f dist/OpenWhisp.dmg
ls -lh dist/OpenWhisp.dmg
# When signing was supposed to happen, REFUSE to publish a DMG that isn't
# actually Developer ID signed + notarized (e.g. a silent fallback to the
# self-signed/ad-hoc identity). A broken release is better absent than shipped.
- name: Verify DMG is signed + notarized
if: steps.signing.outputs.enabled == 'true'
run: ./scripts/verify-signed-notarized.sh dist/OpenWhisp.dmg
# Sparkle appcast (MAK-56). Only runs when the EdDSA private key secret is
# present — forks without it skip this and publish a DMG with no appcast
# (their users just don't get auto-update, everything else works). The
# enclosure points at the TAG-SPECIFIC asset URL so old appcast entries stay
# valid; the feed is hosted via the stable releases/latest/download redirect
# declared in Info.plist's SUFeedURL.
#
# Gated on Developer ID signing being enabled, same as the verify step:
# an EdDSA-signed appcast for an ad-hoc fallback DMG would make the
# installed, notarized fleet auto-update onto an unsigned build (Sparkle
# only checks the EdDSA signature) — breaking every identity-keyed TCC
# grant (mic, accessibility) app-wide. The DMG gets the "refuse to
# publish" treatment; the feed must too.
- name: Generate signed appcast
id: appcast
if: steps.signing.outputs.enabled == 'true'
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
echo "No SPARKLE_ED_PRIVATE_KEY secret — skipping appcast (auto-update disabled for this build)."
echo "generated=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Materialize the pinned Sparkle tools (sign_update) via the fetch script.
eval "$(./scripts/fetch-sparkle.sh)"
TAG="${{ steps.version.outputs.tag }}"
DMG_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/OpenWhisp.dmg"
NOTES_LINK="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
MIN_SYSTEM="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' OpenWhisp/Info.plist)"
./scripts/gen-appcast.sh \
--version "${{ steps.version.outputs.base }}" \
--build "${{ steps.version.outputs.build }}" \
--dmg dist/OpenWhisp.dmg \
--url "$DMG_URL" \
--notes-link "$NOTES_LINK" \
--sign-tool "$SPARKLE_BIN/sign_update" \
--min-system "$MIN_SYSTEM" \
--changelog docs/changelog/changelog.json \
--out dist/appcast.xml
echo "generated=true" >> "$GITHUB_OUTPUT"
echo "----- appcast.xml -----"
cat dist/appcast.xml
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
SIGNED: ${{ steps.signing.outputs.enabled }}
APPCAST: ${{ steps.appcast.outputs.generated }}
run: |
TAG="${{ steps.version.outputs.tag }}"
NOTES_FILE="$(mktemp)"
# Auto-generate the changelog from merged PRs / commits since the
# previous tag. GitHub's generate-notes endpoint builds the categorized
# "What's Changed" list and a full changelog compare link. It needs the
# tag to already exist as a ref, which it does — the tag push is what
# triggered this run. `previous_tag_name` is left unset so GitHub picks
# the last release automatically. Best-effort: if the API call fails
# (e.g. first-ever tag), fall back to a plain commit summary so a
# release still publishes with *some* changelog.
CHANGELOG="$(gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$TAG" \
-f target_commitish="$GITHUB_SHA" \
--jq '.body' 2>/dev/null || true)"
if [ -z "$CHANGELOG" ]; then
echo "::warning::generate-notes returned nothing — using a plain commit list."
PREV_TAG="$(git tag --list 'v*' | grep -v -F "$TAG" | sort -V | tail -1 || true)"
if [ -n "$PREV_TAG" ]; then
CHANGELOG="$(printf '## What'\''s Changed\n\n'; git log --no-merges --pretty='* %s' "${PREV_TAG}..HEAD")"
else
CHANGELOG="$(printf '## What'\''s Changed\n\n'; git log --no-merges --pretty='* %s' -n 30)"
fi
fi
{
echo "$CHANGELOG"
echo ""
echo "---"
echo ""
echo "**Install:** download \`OpenWhisp.dmg\`, open it, drag **OpenWhisp** to Applications."
echo ""
if [ "$SIGNED" = "true" ]; then
echo "> This build is **signed with a Developer ID and notarized by Apple**, so it"
echo "> opens normally — no Gatekeeper warning."
else
echo "> This build is **ad-hoc signed**, so macOS Gatekeeper blocks it on first"
echo "> launch (\"damaged / cannot be opened\"). To open:"
echo "> • **macOS 15+:** launch once, then **System Settings → Privacy & Security → Open Anyway**."
echo "> • **Any version (Terminal):** \`xattr -dr com.apple.quarantine /Applications/OpenWhisp.app\`"
fi
echo ""
if [ "$APPCAST" = "true" ]; then
echo "> Once installed, OpenWhisp keeps itself up to date via **Sparkle** — the"
echo "> update check is EdDSA-signed and the only network call the app makes by"
echo "> default. Turn it off in **Settings → General → Software Update**."
echo ""
fi
echo "See the [README](https://github.com/${GITHUB_REPOSITORY}#readme) for setup and permissions."
} > "$NOTES_FILE"
# Attach the appcast alongside the DMG when it was generated. The
# Info.plist SUFeedURL uses the stable releases/latest/download redirect,
# so this asset becomes the live feed the moment the release publishes.
ASSETS=(dist/OpenWhisp.dmg)
if [ "$APPCAST" = "true" ] && [ -f dist/appcast.xml ]; then
ASSETS+=(dist/appcast.xml)
fi
gh release create "$TAG" \
"${ASSETS[@]}" \
--title "OpenWhisp $TAG" \
--notes-file "$NOTES_FILE" \
--target "$GITHUB_SHA"