-
Notifications
You must be signed in to change notification settings - Fork 453
222 lines (199 loc) · 11 KB
/
Copy pathrelease.yml
File metadata and controls
222 lines (199 loc) · 11 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
name: Release
# Tag a version to ship it: `git tag v0.7.0 && git push origin v0.7.0`.
# A tag with a pre-release suffix (e.g. v0.7.0-beta.1) publishes to the Early Access channel; a plain
# tag (v0.7.0) publishes to everyone. The one-time secret/Pages setup is documented in the
# release-swift skill under .agents/skills/release-swift/.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g. v0.7.0)"
required: true
type: string
permissions:
contents: write
# Serialize releases: each run reads appcast.xml from gh-pages, regenerates it with generate_appcast,
# and force-publishes the branch. Without this, two tags pushed close together could race and drop a
# feed entry.
concurrency:
group: release-appcast
cancel-in-progress: false
jobs:
release:
runs-on: macos-26
steps:
- uses: actions/checkout@v7
with:
# Build the requested tag. On a tag push `inputs.tag` is empty and checkout defaults to the
# triggering tag; on workflow_dispatch this pins the tag instead of the default branch.
ref: ${{ inputs.tag }}
fetch-depth: 0 # full history so the CFBundleVersion (commit count) is correct
persist-credentials: true
- name: Select Xcode
# The app icon is committed prebuilt (see script/compile_icon.sh) because no actool on GitHub's
# runners can compile its Liquid Glass `.icon` (Apple regression FB20183399), so the default
# runner Xcode is fine for the build/sign/notarize steps.
run: sudo xcode-select -s /Applications/Xcode.app
- name: Resolve tag, version, and channel
id: meta
run: |
TAG="${{ inputs.tag }}"
[ -z "$TAG" ] && TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
if [[ "$TAG" == *-* ]]; then CHANNEL=beta; PRERELEASE=true; else CHANNEL=stable; PRERELEASE=false; fi
{
echo "tag=$TAG"
echo "version=$VERSION"
echo "channel=$CHANNEL"
echo "prerelease=$PRERELEASE"
} >> "$GITHUB_OUTPUT"
# Imports the Developer ID cert into a temporary keychain and adds it to the search list (cleaned up
# automatically in a post step). Replaces a hand-rolled `security create-keychain`/`import` block.
- name: Import Developer ID certificate
uses: apple-actions/import-codesign-certs@v7
with:
# Developer ID Application cert (.p12, base64) used to sign the app and DMG.
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Resolve signing identity
run: |
# The cert is now in the search list; codesign needs the identity's display name.
IDENTITY="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/ {print $2; exit}')"
[ -n "$IDENTITY" ] || { echo "No Developer ID Application identity found in the imported cert" >&2; exit 1; }
echo "CODESIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
- name: Write Sparkle private key
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
printf '%s' "$SPARKLE_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key"
echo "SPARKLE_PRIVATE_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Write iCloud provisioning profile
env:
ICLOUD_PROFILE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_ICLOUD_PROFILE }}
run: |
printf '%s' "$ICLOUD_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/openusage-icloud.provisionprofile"
echo "ICLOUD_PROVISIONING_PROFILE=$RUNNER_TEMP/openusage-icloud.provisionprofile" >> "$GITHUB_ENV"
- name: Carry forward the existing appcast
run: |
# generate_appcast works on a folder: it reads the existing appcast.xml there and the new DMG,
# then writes a merged appcast. "feed/" is that folder. Seed it with the live appcast so prior
# versions and the OTHER channel's items are preserved (verified: items whose archive isn't in
# the folder keep their original enclosure URLs untouched).
mkdir -p feed
# Distinguish "no gh-pages yet" (first release -> fresh feed) from "couldn't read it"
# (transient failure). Only start fresh when the branch genuinely doesn't exist; otherwise
# abort so a network blip can't replace the live feed's full history with a single item.
set +e
git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
git fetch origin gh-pages
git show origin/gh-pages:appcast.xml > feed/appcast.xml \
|| { echo "gh-pages exists but appcast.xml is unreadable — aborting to protect the feed." >&2; exit 1; }
echo "Loaded existing appcast from gh-pages."
elif [ "$rc" -eq 2 ]; then
echo "No gh-pages branch yet — starting a fresh feed."
else
echo "Could not reach origin to check for gh-pages — aborting to protect the feed." >&2
exit 1
fi
- name: Build, sign, and notarize the DMG
env:
SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }}
# Apple ID + app-specific password + team ID for notarytool.
NOTARY_APPLE_ID: ${{ secrets.APPLE_ID }}
NOTARY_APP_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
OPENUSAGE_VERSION: ${{ steps.meta.outputs.version }}
run: ./script/release.sh
# Upload the dSYM produced by release.sh so PostHog can symbolicate crash reports (the in-app
# crash autocapture is anonymous and honors the Share-Anonymous-Usage opt-out; see docs/privacy.md).
# posthog-cli is a CI-only build tool (npm), never shipped in the app bundle. Skips with a loud
# warning when POSTHOG_CLI_API_KEY is unset (e.g. forks), so a missing key never blocks a release;
# when the key IS set, an upload failure fails the step so we never silently ship unsymbolicated.
- name: Upload dSYMs to PostHog
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ secrets.POSTHOG_CLI_PROJECT_ID }}
# US region, matching TelemetryConfig.host in the app.
POSTHOG_CLI_HOST: https://us.i.posthog.com
VERSION: ${{ steps.meta.outputs.version }}
run: |
if [ -z "${POSTHOG_CLI_API_KEY}" ]; then
echo "::warning::POSTHOG_CLI_API_KEY not set — skipping dSYM upload; crashes for $VERSION will NOT symbolicate."
exit 0
fi
[ -d dist/dSYMs ] || { echo "dist/dSYMs missing — release.sh did not produce a dSYM." >&2; exit 1; }
npm install -g @posthog/cli
# Symbolication matches a crash report to its symbols by the binary's Mach-O UUID (carried in
# the dSYM), so `--directory` is all that's required — no per-release flags to keep in sync.
posthog-cli dsym upload --directory dist/dSYMs
- name: Publish DMG to GitHub Releases
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: OpenUsage ${{ steps.meta.outputs.version }}
prerelease: ${{ steps.meta.outputs.prerelease }}
files: dist/OpenUsage-${{ steps.meta.outputs.version }}.dmg
- name: Preserve Tauri updater manifest on stable releases
if: steps.meta.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
# Older Tauri builds fetch releases/latest/download/latest.json. Every stable Swift release
# becomes GitHub "Latest", so carry the final Tauri manifest forward on each stable release.
tmpdir="$(mktemp -d)"
gh release download v0.6.28 --pattern latest.json --dir "$tmpdir"
gh release upload "$TAG" "$tmpdir/latest.json" --clobber
- name: Generate appcast with Sparkle
env:
DMG: OpenUsage-${{ steps.meta.outputs.version }}.dmg
CHANNEL: ${{ steps.meta.outputs.channel }}
# Per-tag GitHub release asset base. generate_appcast applies this prefix only to the new DMG
# in feed/; preserved items keep their own (older-tag) URLs.
DOWNLOAD_PREFIX: https://github.com/${{ github.repository }}/releases/download/${{ steps.meta.outputs.tag }}/
run: |
# Locate generate_appcast from the Sparkle SwiftPM artifact that release.sh already fetched.
GA="$(find .build/artifacts -type f -name generate_appcast 2>/dev/null | head -n1)"
[ -x "$GA" ] || { echo "generate_appcast not found under .build/artifacts" >&2; exit 1; }
# How many feed items existed before (to confirm nothing is dropped). 0 on first release.
# Sparkle normally keeps only a few versions per branch; this release feed is intentionally
# append-only so older installs and the other channel's items remain available.
before=0
[ -f feed/appcast.xml ] && before=$(grep -c "<item>" feed/appcast.xml || true)
cp "dist/$DMG" "feed/$DMG"
# Pre-release tags publish to the Early Access (beta) channel; plain tags publish to everyone.
channel_arg=()
[ "$CHANNEL" = "beta" ] && channel_arg=(--channel beta)
# The key file is base64 (it can contain '/'); pass it via stdin so it's never an argv/glob.
cat "$SPARKLE_PRIVATE_KEY_FILE" | "$GA" \
--ed-key-file - \
--download-url-prefix "$DOWNLOAD_PREFIX" \
--maximum-versions 0 \
"${channel_arg[@]}" \
feed
# Fail loudly if the merge went wrong. The new DMG's enclosure must exist AND carry an EdDSA
# signature on the same line (generate_appcast silently omits it if SUPublicEDKey doesn't match
# the signing key), and the item count must not shrink (a dropped preserved/other-channel entry).
after=$(grep -c "<item>" feed/appcast.xml || true)
grep -Eq "$DMG\"[^>]*sparkle:edSignature" feed/appcast.xml \
|| { echo "New item for $DMG is missing or unsigned (SUPublicEDKey/private-key mismatch?)." >&2; exit 1; }
[ "$after" -ge "$before" ] || { echo "Appcast item count dropped ($before -> $after) — aborting." >&2; exit 1; }
echo "Appcast OK: $before -> $after items."
- name: Assemble Pages directory
run: |
mkdir -p public
cp feed/appcast.xml public/appcast.xml
- name: Publish appcast to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: ./public
# gh-pages also hosts pricing_supplement.json (see pricing-supplement.yml). Without
# keep_files, publishing the appcast would delete it from the branch.
keep_files: true