Skip to content

Commit 2676255

Browse files
[CI] Staple the app as well as the image, and verify the drag-installed copy
A notarization ticket stapled only to the .dmg covers the download, but the app the user drags out of it carries none. Its first launch then depends on Gatekeeper reaching Apple, so a user who is offline, behind a captive portal or on a restricted network is told "Apple could not verify ... is free of malware" -- the exact dialog this pipeline exists to prevent. Stapling was gated to the release branch to save a second Apple round trip; a minute of CI is not worth that risk, so it now happens wherever notarization does. The verification step was checking the app while it still sat on the mounted image, which is not the object the user runs. It now dittos the app out, marks the copy with the quarantine attribute a browser sets, and asserts against that copy: the ticket travels with it (stapler validate, so first launch needs no network) and Gatekeeper accepts it (spctl --type exec). The image itself is checked the same way a downloaded file would be. Signed-off-by: Christian Parpart <christian@parpart.family>
1 parent 5805045 commit 2676255

1 file changed

Lines changed: 32 additions & 11 deletions

File tree

.github/workflows/build.yml

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,14 @@ jobs:
448448
echo "CODE_SIGN_CERTIFICATE_ID=-" >> "$GITHUB_OUTPUT"
449449
echo "NOTARIZE=OFF" >> "$GITHUB_OUTPUT"
450450
fi
451-
# Stapling the app *inside* the image is a second Apple round trip. The image's
452-
# own ticket already satisfies Gatekeeper on download; the app's own ticket only
453-
# adds offline first-launch after drag-installing. Worth its minute on something
454-
# users actually download, not on every branch push.
455-
if [[ -n "$HAS_SIGNING_SECRETS" && "$GH_HEAD_REF" == 'release' ]]; then
451+
# Stapled together with the image, never separately. A ticket on the .dmg alone
452+
# covers the download, but the app dragged out of it carries none -- so its first
453+
# launch depends on Gatekeeper reaching Apple, and a user who is offline, behind a
454+
# captive portal or on a restricted network gets exactly the dialog this pipeline
455+
# exists to prevent: "Apple could not verify ... is free of malware". Stapling the
456+
# app makes the installed copy self-sufficient. It costs a second Apple round trip
457+
# of a minute or two, which is not a price worth haggling over against that.
458+
if [[ -n "$HAS_SIGNING_SECRETS" ]]; then
456459
echo "STAPLE_APP=ON" >> "$GITHUB_OUTPUT"
457460
else
458461
echo "STAPLE_APP=OFF" >> "$GITHUB_OUTPUT"
@@ -656,13 +659,31 @@ jobs:
656659
test "$(ls "$MOUNT" | sort | tr '\n' ' ')" = "Applications contour.app "
657660
test "$(ls "$MOUNT/contour.app/Contents/MacOS")" = "contour"
658661
659-
if [[ "${{ steps.set_vars.outputs.NOTARIZE }}" == 'ON' ]]; then
660-
# The decision a user's Gatekeeper makes on first launch.
661-
spctl --assess --verbose=4 --type exec "$MOUNT/contour.app"
662-
fi
663-
if [[ "${{ steps.set_vars.outputs.STAPLE_APP }}" == 'ON' ]]; then
664-
xcrun stapler validate "$MOUNT/contour.app"
662+
if [[ "${{ steps.set_vars.outputs.NOTARIZE }}" != 'ON' ]]; then
663+
echo "::notice::unsigned build (no secrets); skipping Gatekeeper assertions"
664+
exit 0
665665
fi
666+
667+
# Reproduce what the user actually does, because that is where the
668+
# "Apple could not verify ... is free of malware" dialog comes from: they drag
669+
# the app OUT of the image and launch the copy. A ticket stapled only to the
670+
# .dmg does not travel with that copy, leaving first launch dependent on
671+
# Gatekeeper reaching Apple -- which fails offline, behind a captive portal, or
672+
# on a restricted network. So the checks below run against the copy, not against
673+
# the app sitting on the mounted image.
674+
INSTALLED=$(mktemp -d)/contour.app
675+
ditto "$MOUNT/contour.app" "$INSTALLED"
676+
# A downloaded image carries this; the copy must clear Gatekeeper with it set.
677+
xattr -w com.apple.quarantine "0081;00000000;Safari;" "$INSTALLED"
678+
679+
# The ticket is embedded in the copy, so first launch needs no network at all.
680+
xcrun stapler validate "$INSTALLED"
681+
# The exact decision Gatekeeper makes when that copy is first launched.
682+
spctl --assess --verbose=4 --type exec "$INSTALLED"
683+
684+
# And the same for the image a browser just downloaded.
685+
xcrun stapler validate "$DMG"
686+
spctl --assess --verbose=4 --type install "$DMG"
666687
- name: upload to artifact store (DMG)
667688
uses: actions/upload-artifact@v4
668689
with:

0 commit comments

Comments
 (0)