-
Notifications
You must be signed in to change notification settings - Fork 6
388 lines (359 loc) · 16.5 KB
/
release-desktop.yml
File metadata and controls
388 lines (359 loc) · 16.5 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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
name: Release Desktop (Tauri)
on:
push:
tags:
- 'desktop-v*'
workflow_call:
inputs:
version:
required: true
type: string
release_id:
required: false
type: string
description: 'Existing GitHub Release ID to upload artifacts to (skips creating a new release)'
release_draft:
required: false
type: string
description: 'Whether the target GitHub release is currently a draft'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. desktop-v0.1.0)'
required: true
type: string
# Minimal permissions — contents:write for creating releases; id-token and
# attestations:write so actions/attest-build-provenance can publish SLSA
# attestations to the Sigstore transparency log.
permissions:
contents: write
id-token: write
attestations: write
concurrency:
group: release-desktop
cancel-in-progress: false
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
NODE_VERSION: '22'
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
build-and-release:
# Use the 'release' environment for scoped secrets
environment: release
strategy:
fail-fast: false
matrix:
include:
- platform: macos-15
target: aarch64-apple-darwin
label: macOS-arm64
args: --target aarch64-apple-darwin
- platform: macos-15-intel
target: x86_64-apple-darwin
label: macOS-x64
args: --target x86_64-apple-darwin
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
label: Linux-x64
args: --target x86_64-unknown-linux-gnu
- platform: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
label: Linux-arm64
args: --target aarch64-unknown-linux-gnu --bundles deb,rpm
- platform: windows-latest
target: x86_64-pc-windows-msvc
label: Windows-x64
args: --target x86_64-pc-windows-msvc
- platform: windows-11-arm
target: aarch64-pc-windows-msvc
label: Windows-arm64
args: --target aarch64-pc-windows-msvc --bundles nsis
runs-on: ${{ matrix.platform }}
name: Release (${{ matrix.label }})
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (pinned 2026-05-12; bump periodically to track new Rust releases)
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
with:
workspaces: src-tauri -> target
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
for i in 1 2 3; do sudo apt-get update && break || sleep $((i*5)); done
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
libfuse2 \
xdg-utils
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Sync version from package.json to tauri.conf.json + Cargo.toml
# Safeguard: `np`/`pnpm version` should run this via the package.json
# "version" hook, but if it doesn't (e.g. version bump didn't include
# the updated files), artifact filenames would use a stale version.
shell: bash
run: node scripts/sync-version.cjs
- name: Resolve release metadata
id: release_meta
shell: bash
run: |
if [ -n "${{ inputs.version }}" ]; then
TAG_NAME="v${{ inputs.version }}"
RELEASE_NAME="Forward Email v${{ inputs.version }}"
else
RAW_TAG="${{ github.event.inputs.tag || github.ref_name }}"
if [[ "$RAW_TAG" == desktop-v* ]]; then
DISPLAY_VERSION="${RAW_TAG#desktop-v}"
TAG_NAME="$RAW_TAG"
RELEASE_NAME="Forward Email Desktop v${DISPLAY_VERSION}"
elif [[ "$RAW_TAG" == v* ]]; then
DISPLAY_VERSION="${RAW_TAG#v}"
TAG_NAME="$RAW_TAG"
RELEASE_NAME="Forward Email v${DISPLAY_VERSION}"
else
TAG_NAME="v$RAW_TAG"
RELEASE_NAME="Forward Email v${RAW_TAG}"
fi
fi
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "release_name=$RELEASE_NAME" >> "$GITHUB_OUTPUT"
- name: Resolve release ID (prefer published over draft)
id: resolve_release
uses: ./.github/actions/resolve-release
with:
tag: ${{ steps.release_meta.outputs.tag_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable updater artifacts
if: env.TAURI_SIGNING_PRIVATE_KEY != ''
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
shell: node {0}
run: |
const fs = require('fs');
const conf = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf8'));
// pubkey is already committed in tauri.conf.json
conf.bundle.createUpdaterArtifacts = true;
fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(conf, null, 2) + '\n');
# Defensive check: tauri-action silently produces an unsigned macOS
# bundle if any signing/notarization secret is empty at build time.
# When that ships, users see "the application can't be opened" with
# no Gatekeeper dialog because taskgated rejects the binary before
# dyld_start completes (CS_LINKER_SIGNED only, no Developer ID).
# Fail fast here so a misconfigured run is a loud red X instead of
# a broken release.
- name: Require macOS signing secrets on macOS rows
if: runner.os == 'macOS'
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
missing=()
[ -z "$APPLE_CERTIFICATE" ] && missing+=("APPLE_CERTIFICATE")
[ -z "$APPLE_CERTIFICATE_PASSWORD" ] && missing+=("APPLE_CERTIFICATE_PASSWORD")
[ -z "$APPLE_SIGNING_IDENTITY" ] && missing+=("APPLE_SIGNING_IDENTITY")
[ -z "$APPLE_ID" ] && missing+=("APPLE_ID")
[ -z "$APPLE_PASSWORD" ] && missing+=("APPLE_PASSWORD")
[ -z "$APPLE_TEAM_ID" ] && missing+=("APPLE_TEAM_ID")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Refusing to build unsigned macOS bundle on ${{ matrix.label }}. Missing secrets: ${missing[*]}"
exit 1
fi
echo "All Apple signing/notary secrets present for ${{ matrix.label }}."
- name: Build and release Tauri app
uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Tauri updater signing key (generates .sig files for auto-update)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# macOS code signing and notarization
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Windows code signing (optional)
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
with:
tagName: ${{ steps.release_meta.outputs.tag_name }}
releaseName: ${{ steps.release_meta.outputs.release_name }}
releaseId: ${{ steps.resolve_release.outputs.release_id }}
# Always upload to a DRAFT release; the publish-release job at the
# end of this workflow promotes the draft → published only after
# every matrix row passed (build + signing + structural asserts +
# launch-survives smoke). Prevents the v0.10.17–v0.10.21 failure
# class where a release was created and visible to users before
# all rows had verified the bundles open.
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
# Structural asserts on the macOS bundle BEFORE we publish it.
# Catches the class of bug where codesign + notarization succeed at
# build time but the kernel rejects the binary at exec time due to
# an entitlement-vs-cert mismatch (see
# docs/desktop-postmortem-macos-entitlements-2026-05-19.md for the
# 0.10.17–0.10.21 incident this guards against).
#
# Three checks, ordered cheap-to-expensive:
# 1. codesign authority is the Forward Email LLC Developer ID.
# 2. spctl reports "Notarized Developer ID" — Gatekeeper would
# accept it.
# 3. Entitlement allowlist: nothing outside the known-good set is
# embedded. Specifically refuses aps-environment, the entitlement
# that broke 0.10.17–0.10.21.
# 4. Launch-survives test: exec the binary and verify it lives ≥3s
# without being SIGKILLed. Catches taskgated rejection that
# codesign and spctl don't see.
- name: Verify macOS bundle (codesign + entitlements + launch)
if: runner.os == 'macOS'
shell: bash
run: |
set -u
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Forward Email.app"
BIN="$APP/Contents/MacOS/forwardemail-desktop"
if [ ! -d "$APP" ]; then
echo "::error::Bundle not found at $APP — tauri-action did not produce a .app"
exit 1
fi
echo "=== 1. codesign authority ==="
if ! codesign -dv --verbose=4 "$APP" 2>&1 | tee /tmp/cs.out; then
echo "::error::codesign -dv failed — bundle is not signed"
exit 1
fi
if ! grep -q "Authority=Developer ID Application: Forward Email LLC" /tmp/cs.out; then
echo "::error::Bundle is not signed with Forward Email LLC Developer ID"
grep "Authority=" /tmp/cs.out || true
exit 1
fi
if ! grep -q "TeamIdentifier=FH83QMJS7P" /tmp/cs.out; then
echo "::error::Bundle TeamIdentifier does not match Forward Email LLC (FH83QMJS7P)"
exit 1
fi
echo "=== 2. spctl notarization ==="
if ! spctl -a -vvv "$APP" 2>&1 | tee /tmp/spctl.out; then
echo "::error::spctl rejects the bundle"
exit 1
fi
if ! grep -q "Notarized Developer ID" /tmp/spctl.out; then
echo "::error::Bundle is not notarized — Gatekeeper would reject it"
exit 1
fi
echo "=== 3. entitlement allowlist ==="
codesign -d --entitlements - "$APP" 2>&1 | tee /tmp/ents.out
if grep -q "aps-environment" /tmp/ents.out; then
echo "::error::aps-environment entitlement present on macOS Developer ID bundle. The Forward Email LLC cert is not APNs-authorized; the kernel will SIGKILL on launch. See docs/desktop-postmortem-macos-entitlements-2026-05-19.md"
exit 1
fi
echo "=== 4. launch-survives test (3s) ==="
"$BIN" >/tmp/launch.out 2>&1 &
PID=$!
sleep 3
if kill -0 "$PID" 2>/dev/null; then
echo "Bundle survived 3 seconds — codesign/entitlements look OK at exec time"
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
else
echo "::error::Binary exited within 3s of launch. This indicates taskgated rejected the signed bundle — usually an entitlement-vs-cert mismatch. Last output from the binary:"
cat /tmp/launch.out || true
echo ""
echo "Searching for matching crash log…"
log show --predicate 'process == "forwardemail-desktop" AND messageType == fault' --last 1m 2>/dev/null | head -40 || true
exit 1
fi
# SLSA build provenance attestation. Publishes a signed attestation
# to the Sigstore transparency log binding each desktop bundle to
# this workflow run + commit. Users verify with:
# gh attestation verify <file> --owner forwardemail
# Globs that don't match on this matrix row (e.g. msi on macOS) are
# silently skipped by the action.
- name: Attest desktop build provenance
uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2
with:
subject-path: |
src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi
src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage
# E2E webview gate: build the Tauri binary with the webdriver feature
# and run the cross-platform spec suite (incl. native-attachment-add /
# native-attachment-open / native-compose-window) on the same matrix
# we ship. Catches platform-native crash classes (NSOpenPanel, WebKit
# insets, attachment OOM) that the build-and-release verification step
# cannot exercise because it only launches the binary for 3 seconds.
e2e-webview-gate:
name: E2E webview (gate)
uses: ./.github/workflows/e2e-webview.yml
with:
release_gate: true
secrets: inherit
# Release gate: promote the draft release to published ONLY after every
# build-and-release matrix row AND every e2e-webview matrix row passed.
# With `needs:` set, this job is automatically skipped when any
# dependency failed, leaving the release as a draft that the user can
# inspect and either fix and re-run or delete. Prevents partial/broken
# releases from being user-visible (the v0.10.17–v0.10.21 failure mode
# where an unsigned bundle shipped under a published tag).
publish-release:
name: Publish release (gate)
needs: [build-and-release, e2e-webview-gate]
runs-on: ubuntu-latest
environment: release
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Resolve release metadata
id: release_meta
shell: bash
run: |
if [ -n "${{ inputs.version }}" ]; then
TAG_NAME="v${{ inputs.version }}"
else
RAW_TAG="${{ github.event.inputs.tag || github.ref_name }}"
case "$RAW_TAG" in
desktop-v*|v*) TAG_NAME="$RAW_TAG" ;;
*) TAG_NAME="v$RAW_TAG" ;;
esac
fi
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
- name: Promote draft to published
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${{ steps.release_meta.outputs.tag_name }}"
echo "Promoting $TAG from draft to published…"
# `gh release edit` is idempotent — if the release is already
# published this is a no-op rather than an error, which is the
# behavior we want for any manually-published re-runs.
gh release edit "$TAG" --draft=false
echo "Release $TAG is now published."