-
Notifications
You must be signed in to change notification settings - Fork 0
272 lines (254 loc) · 12.6 KB
/
Copy pathrelease.yml
File metadata and controls
272 lines (254 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
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.
#
# 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.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
description: 'Tag to release as (e.g. v0.2.0). Must match Info.plist CFBundleShortVersionString.'
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"
- 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 '"')
- 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