Skip to content

Commit 7d498df

Browse files
committed
ci(release): structural asserts + e2e gate + draft-until-verified
Three improvements to release-desktop.yml that together make the 0.10.17-0.10.21 failure class (a published release tag pointing at unopenable bundles) structurally impossible to ship. Inline verification on each macOS matrix row, after tauri-action: 1. codesign authority must match "Developer ID Application: Forward Email LLC" with TeamIdentifier FH83QMJS7P. 2. spctl reports "source=Notarized Developer ID" (Gatekeeper would accept). 3. Entitlement allowlist: explicit refusal of aps-environment with a doc-pointer error message naming the postmortem, so the next person who hits this finds the writeup in one search. 4. Launch-survives test: exec the binary, sleep 3 seconds, assert the process is still alive. Catches taskgated rejection (the 0.10.17-0.10.21 bug class) because taskgated kills the process within ~50ms of exec — well within the 3s window. E2E gate (new): release-desktop.yml now calls e2e-webview.yml via workflow_call with release_gate=true. The e2e matrix (Linux 22.04, Linux 24.04, Windows x64, macOS arm64, macOS x64) runs in parallel with the build matrix as a gate. Catches platform-native crash classes (NSOpenPanel, WebKit insets, attachment OOM) that the 3s launch smoke can't exercise on its own. Release gate (Layer 4): - tauri-action now uploads to a DRAFT release unconditionally. - New publish-release job depends on both build-and-release AND e2e-webview-gate. Uses `gh release edit --draft=false` to promote draft → published. If any row in either matrix fails, this job is skipped by default GitHub Actions `needs:` semantics, leaving the release as a draft for inspection. Net: a release tag becomes user-visible ONLY after every build row succeeded, every signing + structural assert + launch smoke passed, AND every e2e row passed. Adds ~15-20 min to the release critical path; trades release speed for release confidence. See docs/desktop-postmortem-macos-entitlements-2026-05-19.md for the incident this guards against.
1 parent afa9219 commit 7d498df

11 files changed

Lines changed: 971 additions & 2 deletions

.github/workflows/release-desktop.yml

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,94 @@ jobs:
220220
tagName: ${{ steps.release_meta.outputs.tag_name }}
221221
releaseName: ${{ steps.release_meta.outputs.release_name }}
222222
releaseId: ${{ steps.resolve_release.outputs.release_id }}
223-
releaseDraft: ${{ steps.resolve_release.outputs.release_draft }}
223+
# Always upload to a DRAFT release; the publish-release job at the
224+
# end of this workflow promotes the draft → published only after
225+
# every matrix row passed (build + signing + structural asserts +
226+
# launch-survives smoke). Prevents the v0.10.17–v0.10.21 failure
227+
# class where a release was created and visible to users before
228+
# all rows had verified the bundles open.
229+
releaseDraft: true
224230
prerelease: false
225231
args: ${{ matrix.args }}
226232

233+
# Structural asserts on the macOS bundle BEFORE we publish it.
234+
# Catches the class of bug where codesign + notarization succeed at
235+
# build time but the kernel rejects the binary at exec time due to
236+
# an entitlement-vs-cert mismatch (see
237+
# docs/desktop-postmortem-macos-entitlements-2026-05-19.md for the
238+
# 0.10.17–0.10.21 incident this guards against).
239+
#
240+
# Three checks, ordered cheap-to-expensive:
241+
# 1. codesign authority is the Forward Email LLC Developer ID.
242+
# 2. spctl reports "Notarized Developer ID" — Gatekeeper would
243+
# accept it.
244+
# 3. Entitlement allowlist: nothing outside the known-good set is
245+
# embedded. Specifically refuses aps-environment, the entitlement
246+
# that broke 0.10.17–0.10.21.
247+
# 4. Launch-survives test: exec the binary and verify it lives ≥3s
248+
# without being SIGKILLed. Catches taskgated rejection that
249+
# codesign and spctl don't see.
250+
- name: Verify macOS bundle (codesign + entitlements + launch)
251+
if: runner.os == 'macOS'
252+
shell: bash
253+
run: |
254+
set -u
255+
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Forward Email.app"
256+
BIN="$APP/Contents/MacOS/forwardemail-desktop"
257+
if [ ! -d "$APP" ]; then
258+
echo "::error::Bundle not found at $APP — tauri-action did not produce a .app"
259+
exit 1
260+
fi
261+
262+
echo "=== 1. codesign authority ==="
263+
if ! codesign -dv --verbose=4 "$APP" 2>&1 | tee /tmp/cs.out; then
264+
echo "::error::codesign -dv failed — bundle is not signed"
265+
exit 1
266+
fi
267+
if ! grep -q "Authority=Developer ID Application: Forward Email LLC" /tmp/cs.out; then
268+
echo "::error::Bundle is not signed with Forward Email LLC Developer ID"
269+
grep "Authority=" /tmp/cs.out || true
270+
exit 1
271+
fi
272+
if ! grep -q "TeamIdentifier=FH83QMJS7P" /tmp/cs.out; then
273+
echo "::error::Bundle TeamIdentifier does not match Forward Email LLC (FH83QMJS7P)"
274+
exit 1
275+
fi
276+
277+
echo "=== 2. spctl notarization ==="
278+
if ! spctl -a -vvv "$APP" 2>&1 | tee /tmp/spctl.out; then
279+
echo "::error::spctl rejects the bundle"
280+
exit 1
281+
fi
282+
if ! grep -q "Notarized Developer ID" /tmp/spctl.out; then
283+
echo "::error::Bundle is not notarized — Gatekeeper would reject it"
284+
exit 1
285+
fi
286+
287+
echo "=== 3. entitlement allowlist ==="
288+
codesign -d --entitlements - "$APP" 2>&1 | tee /tmp/ents.out
289+
if grep -q "aps-environment" /tmp/ents.out; then
290+
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"
291+
exit 1
292+
fi
293+
294+
echo "=== 4. launch-survives test (3s) ==="
295+
"$BIN" >/tmp/launch.out 2>&1 &
296+
PID=$!
297+
sleep 3
298+
if kill -0 "$PID" 2>/dev/null; then
299+
echo "Bundle survived 3 seconds — codesign/entitlements look OK at exec time"
300+
kill "$PID" 2>/dev/null || true
301+
wait "$PID" 2>/dev/null || true
302+
else
303+
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:"
304+
cat /tmp/launch.out || true
305+
echo ""
306+
echo "Searching for matching crash log…"
307+
log show --predicate 'process == "forwardemail-desktop" AND messageType == fault' --last 1m 2>/dev/null | head -40 || true
308+
exit 1
309+
fi
310+
227311
# SLSA build provenance attestation. Publishes a signed attestation
228312
# to the Sigstore transparency log binding each desktop bundle to
229313
# this workflow run + commit. Users verify with:
@@ -241,3 +325,64 @@ jobs:
241325
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
242326
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
243327
src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage
328+
329+
# E2E webview gate: build the Tauri binary with the webdriver feature
330+
# and run the cross-platform spec suite (incl. native-attachment-add /
331+
# native-attachment-open / native-compose-window) on the same matrix
332+
# we ship. Catches platform-native crash classes (NSOpenPanel, WebKit
333+
# insets, attachment OOM) that the build-and-release verification step
334+
# cannot exercise because it only launches the binary for 3 seconds.
335+
e2e-webview-gate:
336+
name: E2E webview (gate)
337+
uses: ./.github/workflows/e2e-webview.yml
338+
with:
339+
release_gate: true
340+
secrets: inherit
341+
342+
# Release gate: promote the draft release to published ONLY after every
343+
# build-and-release matrix row AND every e2e-webview matrix row passed.
344+
# With `needs:` set, this job is automatically skipped when any
345+
# dependency failed, leaving the release as a draft that the user can
346+
# inspect and either fix and re-run or delete. Prevents partial/broken
347+
# releases from being user-visible (the v0.10.17–v0.10.21 failure mode
348+
# where an unsigned bundle shipped under a published tag).
349+
publish-release:
350+
name: Publish release (gate)
351+
needs: [build-and-release, e2e-webview-gate]
352+
runs-on: ubuntu-latest
353+
environment: release
354+
timeout-minutes: 5
355+
permissions:
356+
contents: write
357+
steps:
358+
- name: Checkout
359+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
360+
361+
- name: Resolve release metadata
362+
id: release_meta
363+
shell: bash
364+
run: |
365+
if [ -n "${{ inputs.version }}" ]; then
366+
TAG_NAME="v${{ inputs.version }}"
367+
else
368+
RAW_TAG="${{ github.event.inputs.tag || github.ref_name }}"
369+
case "$RAW_TAG" in
370+
desktop-v*|v*) TAG_NAME="$RAW_TAG" ;;
371+
*) TAG_NAME="v$RAW_TAG" ;;
372+
esac
373+
fi
374+
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
375+
376+
- name: Promote draft to published
377+
env:
378+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
379+
shell: bash
380+
run: |
381+
set -euo pipefail
382+
TAG="${{ steps.release_meta.outputs.tag_name }}"
383+
echo "Promoting $TAG from draft to published…"
384+
# `gh release edit` is idempotent — if the release is already
385+
# published this is a no-op rather than an error, which is the
386+
# behavior we want for any manually-published re-runs.
387+
gh release edit "$TAG" --draft=false
388+
echo "Release $TAG is now published."

0 commit comments

Comments
 (0)