Sign builds and releases with a stable code identity

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.
This commit is contained in:
Claude Opus 5
2026-08-05 13:23:09 +06:00
committed by bdeshi
parent 5191a38f7a
commit 061ae098da
5 changed files with 297 additions and 1 deletions
+118
View File
@@ -0,0 +1,118 @@
# Local build configuration. Copy to `.env` (gitignored) and fill in.
#
# cp .env.example .env
#
# build.sh reads `.env` automatically if it exists. Anything already set in
# the environment wins over this file, so a one-off
#
# SHANNONCOAT_SIGN_IDENTITY= ./build.sh
#
# still forces an ad-hoc build without editing anything.
# ---------------------------------------------------------------------------
# Code-signing identity
# ---------------------------------------------------------------------------
# The common name of the code-signing certificate in your keychain. Not a
# secret — it's a label, not a credential — but it lives here so every build
# picks it up without you having to remember the variable.
#
# Why bother signing at all: macOS pins an *ad-hoc* signed app's
# Accessibility grant to its exact cdhash, which changes on every build. So
# an unsigned local build silently loses the permission each time you
# rebuild and reinstall, while still appearing listed and ticked under
# Privacy & Security → Accessibility. Signing with a certificate gives the
# app a stable designated requirement, and the grant survives.
#
# Leave empty (or delete the line) to build ad-hoc.
SHANNONCOAT_SIGN_IDENTITY="shannoncoat Signing"
# ---------------------------------------------------------------------------
# Certificate material — only needed to publish the GitHub Actions secrets
# ---------------------------------------------------------------------------
# The build itself never reads these: once the certificate is imported, the
# private key lives in your keychain and codesign finds it by the identity
# name above. They're recorded here purely so the `gh secret set` commands
# at the bottom have somewhere to read from.
#
# Keep the .p12 OUTSIDE the working tree. A gitignored file is still one
# `git add -f`, one editor-indexed backup, or one shared folder away from
# leaking, and this one holds a private key.
#
# The $HOME below is expanded by the shell when you `source .env` (which is
# how the gh commands at the bottom read it). build.sh parses rather than
# sources this file, so it takes values literally — which costs nothing
# here, as the only variable it actually reads is the identity above.
SHANNONCOAT_P12_PATH="$HOME/.shannoncoat-signing/shannoncoat-signing.p12"
# The .p12 export password. Storing it in plaintext here is weaker than
# leaving it in your password manager and typing it when prompted — prefer
# leaving this field empty and letting `gh secret set` ask for it
# interactively.
#
# Leaving the *field* empty is fine. Exporting the .p12 itself with an
# empty password is not: macOS cannot import one, so CI would fail even
# though local builds carry on working (they import the PEM pair, which
# needs no password). Give the export a real password.
SHANNONCOAT_P12_PASSWORD=""
# ---------------------------------------------------------------------------
# One-time setup
# ---------------------------------------------------------------------------
# Generate a ten-year self-signed code-signing certificate (codesign refuses
# an expired one, and replacing it later resets every user's Accessibility
# permission again):
#
# mkdir -p ~/.shannoncoat-signing && chmod 700 ~/.shannoncoat-signing
# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
# -keyout ~/.shannoncoat-signing/key.pem \
# -out ~/.shannoncoat-signing/cert.pem \
# -subj "/CN=shannoncoat Signing" \
# -addext "basicConstraints=critical,CA:false" \
# -addext "keyUsage=critical,digitalSignature" \
# -addext "extendedKeyUsage=critical,codeSigning"
#
# Bundle it as a .p12. `-legacy` is required: OpenSSL 3's default encoding
# is one macOS cannot read, and `security import` then fails with a
# misleading "MAC verification failed (wrong password?)".
#
# openssl pkcs12 -export -legacy \
# -inkey ~/.shannoncoat-signing/key.pem \
# -in ~/.shannoncoat-signing/cert.pem \
# -name "shannoncoat Signing" \
# -out ~/.shannoncoat-signing/shannoncoat-signing.p12
#
# Import for local builds, then delete the now-redundant loose private key
# (the .p12 remains your only backup, so keep that):
#
# security import ~/.shannoncoat-signing/shannoncoat-signing.p12 \
# -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
# rm ~/.shannoncoat-signing/key.pem
#
# The first signed build raises a "codesign wants to use key…" dialog —
# choose Always Allow. Then re-grant Accessibility one final time; from
# then on it persists across rebuilds.
#
# Note that `security find-identity -v -p codesigning` will report "0 valid
# identities": -v filters to *trusted* certificates and a self-signed one
# reads as CSSMERR_TP_NOT_TRUSTED. codesign uses it regardless. Drop the -v
# to see it.
# ---------------------------------------------------------------------------
# Publishing the same certificate to GitHub Actions
# ---------------------------------------------------------------------------
# Releases need this too — an ad-hoc release revokes the Accessibility
# permission of everyone who updates. .github/workflows/release.yml stays
# inert until all three secrets exist, so nothing breaks in the meantime.
#
# source .env
# base64 -i "$SHANNONCOAT_P12_PATH" | gh secret set SIGNING_CERTIFICATE_P12
# gh secret set SIGNING_IDENTITY --body "$SHANNONCOAT_SIGN_IDENTITY"
# gh secret set SIGNING_CERTIFICATE_PASSWORD # prompts, so it stays out of shell history
#
# This buys permission persistence, not Gatekeeper approval: a self-signed
# certificate isn't notarized, so downloads are still blocked on first open
# and need System Settings → Privacy & Security → Open Anyway.
+108
View File
@@ -21,11 +21,94 @@ permissions:
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:
@@ -34,6 +117,23 @@ jobs:
# 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"
@@ -45,3 +145,11 @@ jobs:
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
+12
View File
@@ -7,6 +7,18 @@ icon/AppIcon.iconset/
# Session handoff notes — local only, never committed
CHECKPOINT.md
# Local build configuration, read by build.sh — holds the code-signing
# identity and, optionally, the certificate password. See .env.example for
# the shape of it. Never committed.
.env
# Belt and braces: private key material must never land in the repo, even
# gitignored. Keep the certificate itself outside the working tree (the
# setup notes in .env.example use ~/.shannoncoat-signing/) — this pattern
# only exists to catch a stray copy before it becomes a commit.
*.p12
*.pem
# Local reference material (e.g. nested checkouts of other projects) — not
# part of this project, never meant to be committed.
.scratch/
+1 -1
View File
@@ -1 +1 @@
0.0.7
0.0.8
+58
View File
@@ -13,6 +13,29 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCES_DIR="$SCRIPT_DIR/Sources"
# Local, gitignored build configuration — currently just the code-signing
# identity, which otherwise has to be remembered and retyped on every
# build. See .env.example.
#
# Parsed rather than sourced, for two reasons: a stray command in the file
# shouldn't execute as a side effect of building, and this lets an existing
# environment variable win, so `SHANNONCOAT_SIGN_IDENTITY= ./build.sh`
# still forces a one-off ad-hoc build without editing the file.
ENV_FILE="$SCRIPT_DIR/.env"
if [[ -f "$ENV_FILE" ]]; then
while IFS='=' read -r key value || [[ -n "$key" ]]; do
key="${key#"${key%%[![:space:]]*}"}" # strip leading whitespace
key="${key#export }"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue # skips blanks and # comments
value="${value%"${value##*[![:space:]]}"}" # strip trailing whitespace
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
[[ -n "${!key+set}" ]] && continue # already in the environment; leave it
printf -v "$key" '%s' "$value"
export "${key?}"
done <"$ENV_FILE"
fi
VERSION_FILE="$SCRIPT_DIR/VERSION"
ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns"
OUT_DIR="${1:-$SCRIPT_DIR/.build}"
@@ -76,6 +99,41 @@ printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT"
swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher"
# Sign the bundle, not just the binary swiftc linker-signed for us — and
# with the bundle's real identifier rather than the "launcher" executable
# name codesign would otherwise infer.
#
# This matters well beyond tidiness, because the window tags need
# Accessibility. macOS ties that grant to a *properly signed* app's
# designated requirement, which survives a rebuild, but falls back to
# pinning an ad-hoc-signed app to its exact cdhash — and every build
# produces a new one. So with the ad-hoc default below, each build +
# reinstall silently invalidates the permission, while the app stays
# listed and stays ticked under Privacy & Security → Accessibility. It
# looks granted and isn't, and no amount of toggling that row fixes it:
# the stale entry has to be removed with "" and the new build added back.
#
# To stop paying that tax on every build, make a self-signed code-signing
# certificate once (Keychain Access → Certificate Assistant → Create a
# Certificate…, type "Code Signing", self-signed) and point this at it:
#
# SHANNONCOAT_SIGN_IDENTITY="My Local Signing Cert" ./build.sh
#
# then re-grant Accessibility one final time. The identity is stable from
# then on, so later builds keep the permission.
if [[ -n "${SHANNONCOAT_SIGN_IDENTITY:-}" ]]; then
# An identity was asked for explicitly, so falling back to ad-hoc would
# quietly reintroduce the very problem it was set to avoid — and a
# release that ships ad-hoc revokes the Accessibility permission of
# everyone who updates. Fail the build instead.
codesign --force --sign "$SHANNONCOAT_SIGN_IDENTITY" \
--identifier "com.local.shannoncoat" "$APP" \
|| { echo "error: signing with \"$SHANNONCOAT_SIGN_IDENTITY\" failed" >&2; exit 1; }
else
codesign --force --sign - --identifier "com.local.shannoncoat" "$APP" >/dev/null 2>&1 \
|| echo "warning: codesign failed; Accessibility permission may not stick" >&2
fi
touch "$APP"
echo "Built: $APP"
echo "Copy it to /Applications (or ~/Applications) to install."