diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml new file mode 100644 index 0000000..f891b10 --- /dev/null +++ b/.github/workflows/app-ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d679f3c..81f14d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,41 +1,21 @@ name: Release -# Triggered by pushing a tag matching `v` (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: @@ -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/-.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 diff --git a/packaging/homebrew/privacycommand.rb b/packaging/homebrew/privacycommand.rb new file mode 100644 index 0000000..f224488 --- /dev/null +++ b/packaging/homebrew/privacycommand.rb @@ -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 diff --git a/scripts/release.sh b/scripts/release.sh index 644fbb4..7a9d0db 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -28,6 +28,10 @@ # # Optional: # SCHEME xcodebuild scheme (default: privacycommand). +# KEYCHAIN_PATH Keychain holding the Developer ID identity. +# Set by CI (the shared workflow's ephemeral +# keychain); omit locally and codesign falls +# back to the default keychain search list. set -euo pipefail @@ -215,8 +219,15 @@ hdiutil create \ "$DMG_PATH" # Sign + staple the DMG itself so the download isn't quarantined on -# first open. -codesign --force --sign "$DEVELOPER_ID" "$DMG_PATH" +# first open. In CI, pin codesign to the ephemeral keychain the cert +# was imported into (KEYCHAIN_PATH, provided by the shared release +# workflow) so it never falls through to an interactive unlock prompt; +# locally, fall back to the default keychain search list. +if [[ -n "${KEYCHAIN_PATH:-}" ]]; then + codesign --force --sign "$DEVELOPER_ID" --keychain "$KEYCHAIN_PATH" "$DMG_PATH" +else + codesign --force --sign "$DEVELOPER_ID" "$DMG_PATH" +fi xcrun notarytool submit "$DMG_PATH" \ --key "$APPLE_API_KEY_PATH" \ --key-id "$APPLE_API_KEY_ID" \