swiftc linker-signs only the inner binary, leaving the bundle unsigned and its codesign identifier as "launcher" rather than the bundle id. More importantly it leaves the app ad-hoc signed, and macOS pins an ad-hoc app's Accessibility grant to its exact cdhash instead of to a designated requirement. Every build mints a new cdhash, so each rebuild-and-replace silently revoked the permission while the app stayed listed and ticked under Privacy & Security — and every release did the same to everyone who updated. That was the root cause of the window tags never appearing. - build.sh signs the bundle with its real identifier, honours SHANNONCOAT_SIGN_IDENTITY, and fails outright rather than falling back to ad-hoc when an identity was asked for explicitly. - The identity is read from a gitignored .env, so it doesn't have to be retyped every build. Parsed rather than sourced, so a stray command in the file can't execute as a side effect of building, and so an existing environment variable still wins. .env.example carries the full one-time setup. - release.yml imports the certificate into a throwaway keychain, builds, verifies, and deletes the keychain on if: always(). It stays inert until the three secrets exist, and fails the release rather than shipping ad-hoc. - Guards the empty-password case explicitly: macOS cannot import an OpenSSL-produced PKCS#12 with an empty password, and reports it as "MAC verification failed (wrong password?)", which sends you hunting for a wrong password rather than a missing one. Nothing local catches this, since the login keychain imports the PEM pair and needs no password. - Ignores *.p12 and *.pem as a backstop; the certificate belongs outside the working tree entirely. Verified end-to-end: two from-scratch builds produce byte-identical designated requirements where ad-hoc differs every time, and six rebuild-reinstall cycles under a real certificate kept the Accessibility grant with no System Settings interaction. This buys permission persistence, not Gatekeeper approval — a self-signed certificate isn't notarized, so downloads still need System Settings -> Privacy & Security -> Open Anyway.
156 lines
6.9 KiB
YAML
156 lines
6.9 KiB
YAML
name: Release
|
|
|
|
# Two ways in: a manually pushed version tag, or a direct call from
|
|
# tag-on-version-bump.yml (which fires on VERSION changes on main). The
|
|
# workflow_call path exists because pushes made with the default
|
|
# GITHUB_TOKEN don't trigger other workflows' `on.push` — a tag pushed by
|
|
# that workflow wouldn't reach this one's `tags:` trigger on its own.
|
|
on:
|
|
push:
|
|
tags:
|
|
- "*"
|
|
workflow_call:
|
|
inputs:
|
|
tag:
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: macos-latest
|
|
env:
|
|
# Not a secret (it's just the certificate's common name), and needed
|
|
# in a step `if:` — where the `secrets` context isn't available, but
|
|
# `env` is. Empty when signing isn't configured, which every step
|
|
# below treats as "fall back to ad-hoc".
|
|
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
ref: ${{ inputs.tag || github.ref }}
|
|
|
|
# Releases need a *stable* code identity, not an ad-hoc one. macOS
|
|
# pins an ad-hoc app's Accessibility grant to its exact cdhash, which
|
|
# changes on every build — so each release would silently revoke the
|
|
# permission users had already granted, leaving a stale entry sitting
|
|
# in System Settings still ticked (and un-fixable by toggling it).
|
|
# A certificate gives the app a designated requirement that survives
|
|
# updates instead.
|
|
#
|
|
# This does NOT make the app Gatekeeper-clean: a self-signed cert
|
|
# isn't notarized, so downloads still need right-click → Open. Only
|
|
# a paid Developer ID plus notarization changes that.
|
|
#
|
|
# One-time setup, ideally with the same self-signed certificate used
|
|
# for local builds (create it with a long validity — codesign refuses
|
|
# to use an expired cert, and re-signing under a new one resets every
|
|
# user's permission again):
|
|
# security export -k login.keychain -t identities -f pkcs12 \
|
|
# -P '<password>' -o cert.p12
|
|
# base64 -i cert.p12
|
|
# then add three repository secrets — SIGNING_CERTIFICATE_P12 (that
|
|
# base64 blob), SIGNING_CERTIFICATE_PASSWORD, and SIGNING_IDENTITY
|
|
# (the certificate's common name).
|
|
#
|
|
# If you build the .p12 with OpenSSL 3 rather than exporting it, pass
|
|
# `-legacy`: its default AES-256/SHA-256 PKCS#12 encoding is one the
|
|
# macOS Security framework can't read, and `security import` fails
|
|
# with a misleading "MAC verification failed (wrong password?)".
|
|
- name: Import signing certificate
|
|
if: env.SIGNING_IDENTITY != ''
|
|
env:
|
|
CERT_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }}
|
|
CERT_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# macOS cannot import an OpenSSL-produced PKCS#12 that has an
|
|
# empty password — the two disagree over the spec's empty-string
|
|
# vs. NULL password ambiguity, and `security import` reports it
|
|
# as "MAC verification failed (wrong password?)", which sends you
|
|
# hunting for a wrong password rather than a missing one. Say
|
|
# what's actually wrong instead.
|
|
#
|
|
# Easy to hit, because a local build never exercises this: the
|
|
# local keychain imports the PEM pair directly and needs no
|
|
# password at all.
|
|
if [[ -z "${CERT_PASSWORD:-}" ]]; then
|
|
echo "error: SIGNING_CERTIFICATE_PASSWORD is empty." >&2
|
|
echo "The .p12 must be exported with a non-empty password; regenerate it with" >&2
|
|
echo " openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out cert.p12" >&2
|
|
echo "and update both SIGNING_CERTIFICATE_P12 and SIGNING_CERTIFICATE_PASSWORD." >&2
|
|
exit 1
|
|
fi
|
|
|
|
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
|
|
KEYCHAIN_PASSWORD="$(uuidgen)"
|
|
CERT_PATH="$RUNNER_TEMP/cert.p12"
|
|
|
|
printf '%s' "$CERT_P12" | base64 --decode > "$CERT_PATH"
|
|
|
|
# A dedicated throwaway keychain, not the login one: it starts
|
|
# unlocked, needs no interactive prompt, and is deleted at the
|
|
# end of the job regardless of outcome.
|
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
|
|
|
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
|
|
-T /usr/bin/codesign
|
|
# Without this, codesign hits an interactive "allow access to
|
|
# key?" prompt that nothing can answer on a headless runner.
|
|
security set-key-partition-list -S apple-tool:,apple: \
|
|
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
|
|
# codesign only searches keychains on the search list.
|
|
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
|
|
|
|
rm -f "$CERT_PATH"
|
|
|
|
- name: Build shannoncoat.app
|
|
run: ./build.sh
|
|
env:
|
|
# This workflow only ever runs against a tag (see the trigger
|
|
# comment above) — told to build.sh directly rather than having
|
|
# it re-derive that via `git describe`, which needs full tag
|
|
# refs a CI runner's checkout isn't guaranteed to have fetched.
|
|
SHANNONCOAT_RELEASE_BUILD: "1"
|
|
# Empty when unconfigured, which build.sh reads as ad-hoc.
|
|
SHANNONCOAT_SIGN_IDENTITY: ${{ env.SIGNING_IDENTITY }}
|
|
|
|
# Catches a release that silently went out ad-hoc — the failure this
|
|
# whole arrangement exists to prevent, and one that's invisible in
|
|
# the artifact until someone's permission stops working.
|
|
- name: Verify signature
|
|
run: |
|
|
set -euo pipefail
|
|
codesign --verify --strict --verbose=2 ".build/shannoncoat.app"
|
|
codesign -dvvv ".build/shannoncoat.app" 2>&1 \
|
|
| grep -E 'Identifier=|Authority=|Signature=' || true
|
|
if [[ -n "$SIGNING_IDENTITY" ]] \
|
|
&& codesign -dvvv ".build/shannoncoat.app" 2>&1 | grep -q 'Signature=adhoc'; then
|
|
echo "error: signing was configured but the app is still ad-hoc signed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Zip app bundle
|
|
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
|
|
|
|
- name: Publish release
|
|
uses: softprops/action-gh-release@v2
|
|
with:
|
|
tag_name: ${{ inputs.tag || github.ref_name }}
|
|
files: |
|
|
shannoncoat.app.zip
|
|
generate_release_notes: true
|
|
|
|
# `if: always()` so a failed build can't leave the signing key
|
|
# sitting in a keychain on the runner.
|
|
- name: Clean up keychain
|
|
if: always() && env.SIGNING_IDENTITY != ''
|
|
run: |
|
|
security list-keychain -d user -s login.keychain-db || true
|
|
security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true
|