Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/app-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: App CI

# Builds and tests the Xcode app target (privacycommand.app +
# privacycommandTests) via the shared reusable workflow — unsigned, no
# secrets. Complements ci.yml, which covers the SPM package targets
# (Core, auditctlKit, auditctl, guest agent) on pull requests.

on:
push:
branches: [main]
pull_request:
branches: [main]

# A new push to the same ref/PR cancels an in-flight run. Declared here
# as well as in the called workflow — GitHub evaluates workflow-level
# concurrency of a *called* workflow inconsistently.
concurrency:
group: app-ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
ci:
uses: privacykey/gh-workflows/.github/workflows/macos-app-ci.yml@v1
with:
xcodeproj: privacycommand/privacycommand.xcodeproj
scheme: privacycommand
307 changes: 50 additions & 257 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,41 +1,21 @@
name: Release

# Triggered by pushing a tag matching `v<semver>` (e.g. v0.2.0). The
# tag name becomes the release version; CFBundleShortVersionString in
# Info.plist must match before the workflow will publish.
# Thin caller — the actual pipeline (test gate → sign → notarize → DMG →
# appcast → GitHub Release → Homebrew cask) lives in
# privacykey/gh-workflows (.github/workflows/macos-sparkle-release.yml,
# pinned @v1). See that repo's README for the release-script contract
# and the secret layout.
#
# Outputs:
# • Signed + notarized DMG attached to the GitHub Release.
# • Updated appcast.xml committed to the gh-pages branch so Sparkle
# clients pick up the new version on their next check.
#
# Required secrets — split across organisation and environment scopes
# (see docs/RELEASES.md for the rationale):
#
# Organisation secrets (set once on the privacykey org, available to
# every repo that needs Apple signing — privacycommand, privacytracker,
# any future macOS app):
# APPLE_CERTIFICATE — base64 .p12 of the Developer ID
# Application cert + private key.
# APPLE_CERTIFICATE_PASSWORD — passphrase for the .p12 above.
# APPLE_SIGNING_IDENTITY — common-name string, e.g.
# "Developer ID Application: PrivacyKey (TEAMID)".
# APPLE_API_KEY — full PEM contents of the App Store
# Connect API .p8 file.
# APPLE_API_KEY_ID — 10-char Key ID from App Store Connect.
# APPLE_API_ISSUER — Issuer UUID from App Store Connect.
#
# Environment secrets (this repo's `macos-signing` environment, gated
# by required-reviewer rule — per-app, never share across repos):
# SPARKLE_PRIVATE_KEY — base64 EdDSA private key from
# Sparkle's `generate_keys`. Public
# half is in Info.plist's
# SUPublicEDKey.
#
# The `environment: macos-signing` line below wires both scopes in: org
# secrets are visible automatically, environment secrets are visible
# because the job opted into that environment, and the required-reviewer
# rule pauses the run for human approval before any of them are read.
# Secret scopes (names only; values live in GitHub):
# • APPLE_* — privacykey org secrets, shared by every macOS app repo.
# • SPARKLE_PRIVATE_KEY — this repo (intended home: the macos-signing
# environment). Environment secrets only resolve inside the called
# workflow's release job — which declares `environment:
# macos-signing` — so this caller MUST use `secrets: inherit`;
# an explicit `secrets:` mapping resolves in the caller's context
# (no environment) and would come back empty.
# • HOMEBREW_TAP_TOKEN — optional; the cask-publish step skips
# cleanly until it exists.

on:
push:
Expand All @@ -44,229 +24,42 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: 'Tag to release as (e.g. v0.2.0). Must match Info.plist CFBundleShortVersionString.'
description: 'Tag to release as (e.g. v0.2.0). Must match MARKETING_VERSION.'
required: true

jobs:
build:
# macos-15 ships with Xcode 16 by default — required because the
# project file uses objectVersion 70 (Xcode 16+ format). macos-14
# has Xcode 16 installed but defaults to 15.x, which can't open
# the project. The setup-xcode step below makes the version
# selection explicit and reproducible regardless of any future
# changes Apple makes to the runner image's defaults.
runs-on: macos-15
# Apple identity is org-level; Sparkle updater key is env-level.
# Opting into the environment also enforces the required-reviewer
# rule — the workflow pauses in the Actions UI until a human
# approves it. Even a stolen PAT can't ship a release without
# explicit approval.
environment: macos-signing

permissions:
contents: write

steps:
- name: Check out source
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Select Xcode
# Pin to whatever the runner image considers the latest stable
# Xcode. Acceptable here because the project format is forward-
# compatible (a newer Xcode can always open an older project),
# so we benefit from picking up Xcode security fixes without
# workflow edits. If you ever need a *specific* version (e.g.
# to reproduce a bug, or to gate on an SDK feature), pin it
# explicitly here, e.g. `xcode-version: '16.4'`.
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Resolve release tag
id: resolve
run: |
TAG="${{ github.event.inputs.release_tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Releasing as $TAG"

- name: Verify tag matches Marketing Version
# Info.plist now contains $(MARKETING_VERSION) as a placeholder
# — Xcode resolves it at build time from the project's build
# settings. PlistBuddy can't see that, so we ask xcodebuild to
# show us the resolved value directly. -showBuildSettings is a
# read-only dry-run; it doesn't trigger an actual build, but
# does load the project (so Select Xcode must run first — it
# does, see the step above).
run: |
set -euo pipefail
TAG_VERSION="${{ steps.resolve.outputs.tag }}"
TAG_VERSION="${TAG_VERSION#v}"
PROJECT_VERSION=$(xcodebuild \
-project privacycommand/privacycommand.xcodeproj \
-target privacycommand \
-configuration Release \
-showBuildSettings \
| awk '$1 == "MARKETING_VERSION" { print $3; exit }')
if [[ -z "$PROJECT_VERSION" ]]; then
echo "error: could not read MARKETING_VERSION from privacycommand target" >&2
exit 1
fi
if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then
echo "error: tag $TAG_VERSION does not match Marketing Version $PROJECT_VERSION" >&2
echo " Either bump Marketing Version in Xcode → privacycommand → General → Identity," >&2
echo " or push a tag matching the current value." >&2
exit 1
fi
echo "Marketing Version $PROJECT_VERSION matches tag $TAG_VERSION"
# Deliberate duplication of the called workflow's concurrency block:
# GitHub evaluates workflow-level concurrency of a *called* workflow
# inconsistently, so both sides declare it.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

- name: Set up keychain + Developer ID cert
env:
CERT_BASE64: ${{ secrets.APPLE_CERTIFICATE }}
CERT_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
KEYCHAIN_PASSWORD="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo -n "$CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" \
-P "$CERT_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s \
"$KEYCHAIN_PATH" \
$(security list-keychains -d user | tr -d '"')
# A called workflow can only reduce the caller's token permissions,
# never raise them — contents: write is needed to publish the Release
# and push the appcast branch. Inside the reusable workflow the test
# job runs with contents: read; only its release job uses write.
permissions:
contents: write

- name: Write App Store Connect API key
# notarytool's --key flag wants a file path, but the secret
# stores the .p8 contents. Stage it under $RUNNER_TEMP with
# mode 600. The runner VM is destroyed at job end, but the
# `Clean up signing material` step below also removes it
# explicitly so a future edit that uploads $RUNNER_TEMP
# doesn't leak it.
id: write-api-key
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
set -euo pipefail
p8_path="${RUNNER_TEMP}/appstoreconnect.p8"
umask 077
printf '%s' "${APPLE_API_KEY}" > "${p8_path}"
echo "p8_path=${p8_path}" >> "$GITHUB_OUTPUT"

- name: Build, sign, and notarize DMG
env:
# release.sh consumes these — see header comments there.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_PATH: ${{ steps.write-api-key.outputs.p8_path }}
run: |
# The actual xcodebuild + create-dmg + notarytool sequence is
# in scripts/release.sh so it can also be run locally for
# dry-runs. Outputs ./dist/<name>-<version>.dmg.
./scripts/release.sh

- name: Install Sparkle CLI tools
# Sparkle's `generate_appcast` ships precompiled inside the
# release tarball at https://github.com/sparkle-project/Sparkle/releases.
# The SPM checkout we link against at runtime only contains the
# framework — not the signing CLI — so we pull the tarball
# explicitly here and prepend its bin/ to PATH.
#
# SPARKLE_VERSION should track the runtime Sparkle version
# checked out by SwiftPM (look at privacycommand/.build/checkouts/
# Sparkle/CHANGELOG, the top entry is the version you're on).
# Minor mismatches between CLI and framework are fine within the
# 2.x line — the appcast / signature formats are stable — but
# bumping the runtime warrants bumping this too so you don't
# drift indefinitely.
env:
SPARKLE_VERSION: '2.9.1'
run: |
set -euo pipefail
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" \
| tar -xJ -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Sanity-check the binary is where we expect.
test -x "$RUNNER_TEMP/bin/generate_appcast" \
|| { echo "::error::generate_appcast missing after extracting Sparkle ${SPARKLE_VERSION}"; exit 1; }

- name: Generate appcast
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
# Point the appcast's enclosure URLs at the GitHub Releases
# CDN for *this specific tag* — that's where the DMG actually
# lives. Without this, generate_appcast defaults to the
# appcast's own location (gh-pages), which 404s for the DMG.
# Per-tag rather than /latest/download/ so old appcast items
# keep resolving once newer releases ship.
DOWNLOAD_URL_PREFIX: "https://github.com/${{ github.repository }}/releases/download/${{ steps.resolve.outputs.tag }}/"
run: ./scripts/generate-appcast.sh ./dist

- name: Stage dSYM for upload
# release.sh wrote the dSYM zip to ./symbols/ (kept out of dist/
# so generate-appcast above didn't pick it up as a release
# archive). Now that the appcast is generated, move it into
# dist/ for the Release asset upload below.
run: |
set -euo pipefail
if compgen -G "symbols/*.dSYM.zip" > /dev/null; then
mv symbols/*.dSYM.zip dist/
else
echo "::warning::no dSYM zip in symbols/; field crashes for this release won't be symbolicatable"
fi

- name: Attach DMG + dSYM to GitHub Release
uses: softprops/action-gh-release@v3
with:
# Multiple globs: DMG (the actual download) + dSYM zip
# (debug-symbol bundle, useful for symbolicating user crash
# reports — anyone with a stack trace can download this and
# run `atos`).
files: |
dist/*.dmg
dist/*.dSYM.zip
tag_name: ${{ steps.resolve.outputs.tag }}
generate_release_notes: true
fail_on_unmatched_files: true

- name: Archive dSYM as long-retention workflow artefact
# Belt-and-braces: keep a copy of the dSYM in the workflow run's
# artefacts even if the Release is later deleted or the asset
# gets removed. 365-day retention is the GitHub maximum.
if: hashFiles('dist/*.dSYM.zip') != ''
uses: actions/upload-artifact@v4
with:
name: dSYM-${{ steps.resolve.outputs.tag }}
path: dist/*.dSYM.zip
retention-days: 365
if-no-files-found: warn

- name: Publish appcast to gh-pages
run: |
# Publish appcast.xml to the gh-pages branch so Sparkle
# clients can fetch it without a release-page round-trip.
# We use a worktree so the existing checkout is undisturbed.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin gh-pages || true
git worktree add ../gh-pages-checkout gh-pages 2>/dev/null \
|| git worktree add -b gh-pages ../gh-pages-checkout origin/gh-pages \
|| git worktree add -b gh-pages ../gh-pages-checkout
cp dist/appcast.xml ../gh-pages-checkout/appcast.xml
(cd ../gh-pages-checkout && \
git add appcast.xml && \
git commit -m "Release ${{ steps.resolve.outputs.tag }}" && \
git push origin gh-pages)

- name: Clean up signing material
# Always runs — even if signing failed — so the .p8 doesn't
# survive one second longer than it has to.
if: always()
run: |
rm -f "${{ steps.write-api-key.outputs.p8_path }}" 2>/dev/null || true
rm -f "$RUNNER_TEMP/cert.p12" 2>/dev/null || true
security delete-keychain "$RUNNER_TEMP/build.keychain" 2>/dev/null || true
jobs:
release:
uses: privacykey/gh-workflows/.github/workflows/macos-sparkle-release.yml@v1
with:
xcodeproj: privacycommand/privacycommand.xcodeproj
scheme: privacycommand
# The .xcodeproj is committed, not generated.
uses_xcodegen: false
appcast_branch: gh-pages
# Attach the dSYM to the public Release so anyone with a crash
# log can symbolicate; the reusable default (false) would keep
# symbols private-only.
publish_dsym: true
# Renders packaging/homebrew/privacycommand.rb and pushes it to
# privacykey/homebrew-tap. Skips cleanly until the
# HOMEBREW_TAP_TOKEN secret exists.
cask_name: privacycommand
tap_repo: privacykey/homebrew-tap
# Empty on tag pushes (the reusable workflow falls back to the
# triggering ref name); set by workflow_dispatch re-releases.
release_tag: ${{ inputs.release_tag || '' }}
secrets: inherit
35 changes: 35 additions & 0 deletions packaging/homebrew/privacycommand.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
cask "privacycommand" do
# TEMPLATE — rendered by the shared release workflow
# (privacykey/gh-workflows macos-sparkle-release.yml): @@VERSION@@,
# @@SHA256@@ and @@URL@@ are substituted per release and the result is
# pushed to privacykey/homebrew-tap/Casks/privacycommand.rb. Do not
# hand-edit version/sha256/url here.
version "@@VERSION@@"
sha256 "@@SHA256@@"

url "@@URL@@"
name "privacycommand"
desc "Inspect macOS app bundles for privacy and security findings"
homepage "https://github.com/privacykey/privacycommand"

# Sparkle's appcast lives on gh-pages. Linking it here gives Cask
# users a sanity check that the version they're installing matches
# what the in-app updater would otherwise pull.
livecheck do
url "https://privacykey.github.io/privacycommand/appcast.xml"
strategy :sparkle
end

app "privacycommand.app"

# We're not sandboxed, so quitting the app is enough — no need for
# a tighter `quit:` predicate. The in-app Sparkle updater is
# suppressed for Cask installs by the UpdateController's
# HomebrewDetector.
zap trash: [
"~/Library/Application Support/privacycommand",
"~/Library/Caches/org.privacykey.privacycommand",
"~/Library/Preferences/org.privacykey.privacycommand.plist",
"~/Library/Saved Application State/org.privacykey.privacycommand.savedState",
]
end
Loading