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@v7 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 '' -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@v3 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