-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrelease.sh
More file actions
executable file
·319 lines (295 loc) · 14.2 KB
/
Copy pathrelease.sh
File metadata and controls
executable file
·319 lines (295 loc) · 14.2 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
#!/bin/bash
# Builds, packages, notarizes and staples a DMG, and publishes the release that every
# installed copy updates from. Adapted from Subtitles' release.sh.
#
# Prerequisites, one-time:
# - a "Developer ID Application" certificate in the login keychain
# - notarization credentials stored as a keychain profile (Subtitles' works as is):
# xcrun notarytool store-credentials "subtitles-notary" \
# --apple-id <you@example.com> --team-id <TEAMID> --password <app-specific>
# - the Sparkle signing key in the login keychain, whose public half is SUPublicEDKey in
# project.yml. Back it up (generate_keys -x): lose it and every installed copy refuses
# every future update.
# - `gh`, logged in, and xcodegen
#
# The GitHub release is the download. Three assets: the DMG under the stable name
# Layland.dmg (layland.app/download redirects to releases/latest/download/Layland.dmg), the
# zip Sparkle installs from, and appcast.xml, which layland.app/appcast.xml proxies from
# releases/latest/download/ — so nothing on the site changes per release. The release is created as a draft and published
# only once every asset is up, so no check sees an appcast whose zip is still uploading.
#
# ./release.sh the real thing
# ./release.sh --no-notarize build the DMG and stop (NOT shippable)
# ./release.sh --dry-run the publishing half against a draft it deletes again
# ./release.sh --critical mark the update critical (no Skip)
set -euo pipefail
cd "$(dirname "$0")"
PROFILE="${LAYLAND_NOTARY_PROFILE:-subtitles-notary}"
NOTARIZE=yes
CRITICAL=no
DRYRUN=no
for arg in "$@"; do
case "$arg" in
--no-notarize) NOTARIZE=no ;;
--critical) CRITICAL=yes ;;
--dry-run) DRYRUN=yes; NOTARIZE=no ;;
*) echo "usage: release.sh [--no-notarize | --dry-run] [--critical]" >&2; exit 1 ;;
esac
done
setting() { grep -m1 "^ *$1:" project.yml | sed -E 's/^[^:]*: *"?([^"]*)"?.*$/\1/'; }
VERSION=$(setting MARKETING_VERSION)
BUILD=$(setting CURRENT_PROJECT_VERSION)
FEED_URL=$(setting SUFeedURL)
DERIVED="build/DerivedData"
BUILT_APP="$DERIVED/Build/Products/Release/Layland.app"
OUT="build/release"
APP="$OUT/Layland.app"
STAGE="$OUT/dmg"
DMG="$OUT/Layland-$VERSION.dmg"
STABLE_DMG="$OUT/Layland.dmg"
ZIP="$OUT/Layland-$VERSION.zip"
FEED_DIR="$OUT/feed"
GENERATE_APPCAST="$DERIVED/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast"
REPO="daformat/layland"
RELEASES="https://github.com/$REPO/releases"
TAG="v$VERSION"
echo "==> release $VERSION (build $BUILD)"
# Everything the tail needs, checked before the notarization round trip.
if [ "$NOTARIZE" = yes ] || [ "$DRYRUN" = yes ]; then
command -v gh >/dev/null || { echo "!! gh is not installed" >&2; exit 1; }
gh auth status >/dev/null 2>&1 || { echo "!! gh is not logged in" >&2; exit 1; }
# A version with no notes is not one to ship; this exits 1 and says so.
tools/changelog-notes.py "$VERSION" >/dev/null
if [ "$DRYRUN" = no ] && git rev-parse "$TAG" >/dev/null 2>&1; then
echo "!! $TAG is already tagged — bump MARKETING_VERSION and CURRENT_PROJECT_VERSION in project.yml" >&2; exit 1
fi
if gh release view "$TAG" -R "$REPO" >/dev/null 2>&1; then
echo "!! a release $TAG already exists on GitHub (a leftover draft, perhaps):" >&2
echo " gh release delete $TAG -R $REPO --yes" >&2; exit 1
fi
fi
# "Which commit was that build from" needs an answer.
if [ "$NOTARIZE" = yes ] && [ -n "$(git status --porcelain)" ]; then
echo "!! working tree is dirty — commit or stash before releasing" >&2
git status --short >&2
exit 1
fi
echo "==> building"
xcodegen generate >/dev/null
# --timestamp: notarization requires a secure timestamp on every signature, which a plain
# `xcodebuild build` leaves out.
xcodebuild -scheme Layland -configuration Release -destination "generic/platform=macOS" -derivedDataPath "$DERIVED" \
OTHER_CODE_SIGN_FLAGS="--timestamp" CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO build \
| grep -E "error|warning: .*Layland|BUILD" || true
[ -d "$BUILT_APP" ] || { echo "!! build failed" >&2; exit 1; }
rm -rf "$OUT"; mkdir -p "$OUT"
ditto "$BUILT_APP" "$APP"
# Signed again here, inside out, because Xcode's copy of Sparkle re-signs only the framework
# and leaves the executables nested in it (Autoupdate, Updater.app, the XPC services) with
# Sparkle's own signatures, which notarization rejects. The XPC services exist for sandboxed
# apps; this one is not, so they go. The app itself needs no entitlements, and re-signing it
# without any also drops the get-task-allow Xcode adds for debugging.
echo "==> signing"
SIGN=(codesign --force --timestamp --options runtime --sign "Developer ID Application")
SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
rm -rf "$SPARKLE/Versions/B/XPCServices" "$SPARKLE/XPCServices"
for nested in "$SPARKLE/Versions/B/Autoupdate" "$SPARKLE/Versions/B/Updater.app"; do
"${SIGN[@]}" "$nested"
done
"${SIGN[@]}" "$SPARKLE"
"${SIGN[@]}" "$APP"
# Two traps, both of which make a correctly signed app look unsigned: -dvv, not -dv (the
# Authority lines only appear at the second v), and captured, not piped (grep -q exits at
# the first match and pipefail fails on codesign's SIGPIPE).
SIG_INFO=$(codesign -dvv "$APP" 2>&1 || true)
if ! grep -q "Authority=Developer ID Application" <<<"$SIG_INFO"; then
echo "!! $APP is not signed with a Developer ID — cannot notarize" >&2
echo " check: security find-identity -v -p codesigning" >&2
exit 1
fi
grep -q "^Timestamp=" <<<"$SIG_INFO" || { echo "!! $APP has no secure timestamp — notarization would refuse it" >&2; exit 1; }
codesign --verify --strict --deep "$APP"
# What notarization checks, checked here first: every executable in the bundle signed with
# the Developer ID and timestamped, and no debugging entitlement.
while IFS= read -r -d '' binary; do
file "$binary" | grep -q "Mach-O" || continue
info=$(codesign -dvv "$binary" 2>&1 || true)
if ! grep -q "Authority=Developer ID Application" <<<"$info" || ! grep -q "^Timestamp=" <<<"$info"; then
echo "!! ${binary#$OUT/} is not signed with a timestamped Developer ID" >&2; exit 1
fi
done < <(find "$APP" -type f -perm -u+x -print0)
if codesign -d --entitlements - "$APP" 2>/dev/null | grep -q "get-task-allow"; then
echo "!! $APP carries the get-task-allow entitlement — notarization would refuse it" >&2; exit 1
fi
[ "$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$APP/Contents/Info.plist")" = "$BUILD" ] \
|| { echo "!! the app's CFBundleVersion is not $BUILD" >&2; exit 1; }
echo "==> packaging $DMG"
RWDMG="$OUT/Layland-rw.dmg"
mkdir -p "$STAGE/.background"
cp -R "$APP" "$STAGE/"
# The drag-to-install target.
ln -s /Applications "$STAGE/Applications"
swift tools/makedmgbg.swift "$STAGE/.background/background.tiff"
# A volume of this name already mounted (a previous run that died before detaching) makes
# hdiutil name the new one "Layland 1", and the layout below would then style the stale one.
while read -r stale; do
[ -n "$stale" ] || continue
echo " detaching stale volume: $stale"
hdiutil detach "$stale" -quiet -force 2>/dev/null || true
done < <(mount | awk -F' on | \\(' '/\/Volumes\/Layland/ {print $2}')
# Read-write first: the window layout lives in the volume's .DS_Store, which only Finder
# writes, and only on a mounted writable image. The compressed image is converted from it.
hdiutil create -volname "Layland" -srcfolder "$STAGE" -ov -format UDRW -fs HFS+ "$RWDMG" >/dev/null
MOUNT=$(hdiutil attach "$RWDMG" -readwrite -noverify -noautoopen | tail -1 | awk -F'\t' '{print $NF}')
trap 'hdiutil detach "$MOUNT" -quiet -force 2>/dev/null || true' EXIT
VOLNAME=$(basename "$MOUNT")
# Coordinates match tools/makedmgbg.swift, in AppleScript's space (points, origin at the
# window's top left). Unquoted heredoc so it interpolates: no $, backslash or backtick in
# the script, not even in its comments.
if ! osascript <<APPLESCRIPT
tell application "Finder"
tell disk "$VOLNAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
-- 428, not 400: the bounds include the title bar.
set the bounds of container window to {240, 130, 880, 558}
set opts to the icon view options of container window
set arrangement of opts to not arranged
set icon size of opts to 128
set text size of opts to 12
set background picture of opts to file ".background:background.tiff"
set position of item "Layland.app" of container window to {170, 180}
set position of item "Applications" of container window to {470, 180}
-- Re-asserted after the contents change, or Finder falls back to its default width.
set the bounds of container window to {240, 130, 880, 558}
update without registering applications
delay 1
-- Closing is what commits .DS_Store.
close
end tell
end tell
APPLESCRIPT
then
echo "!! Finder refused the layout script (Apple event error)." >&2
echo " Laying out a DMG window means driving Finder, which macOS gates behind Automation" >&2
echo " permission: System Settings > Privacy & Security > Automation >" >&2
echo " <your terminal> > Finder, then run this again." >&2
exit 1
fi
# Finder writes .DS_Store lazily; detaching before it lands loses the layout.
sync
sleep 2
hdiutil detach "$MOUNT" -quiet
trap - EXIT
hdiutil convert "$RWDMG" -format UDZO -imagekey zlib-level=9 -o "$DMG" >/dev/null
rm -f "$RWDMG"
rm -rf "$STAGE"
echo " $(du -h "$DMG" | cut -f1)"
# Signed too, so Gatekeeper has something to check before anything is mounted.
codesign --force --sign "Developer ID Application" --timestamp "$DMG"
if [ "$NOTARIZE" = no ] && [ "$DRYRUN" = no ]; then
echo
echo "built $DMG — NOT notarized, do not ship this one"
exit 0
fi
if [ "$DRYRUN" = no ]; then
echo "==> notarizing (a few minutes)"
# --wait returns normally even when Apple rejects the submission, so the verdict is read
# from the output: anything but Accepted stops here, with Apple's log, rather than at a
# stapling error that says nothing about why.
NOTARY=$(xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait --output-format json)
STATUS=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("status",""))' "$NOTARY")
SUBMISSION=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("id",""))' "$NOTARY")
echo " $STATUS ($SUBMISSION)"
if [ "$STATUS" != "Accepted" ]; then
echo "!! notarization $STATUS — Apple's log:" >&2
xcrun notarytool log "$SUBMISSION" --keychain-profile "$PROFILE" >&2 || true
exit 1
fi
echo "==> stapling"
xcrun stapler staple "$DMG"
echo "==> verifying"
xcrun stapler validate "$DMG"
spctl -a -t open --context context:primary-signature -v "$DMG"
fi
# The update archive. The app is stapled first (the DMG's ticket covers it, so no second
# round trip), so the copy Sparkle installs carries its own proof.
echo "==> update archive"
[ "$DRYRUN" = no ] && xcrun stapler staple "$APP"
ditto -c -k --keepParent "$APP" "$ZIP"
echo " $(du -h "$ZIP" | cut -f1)"
# The appcast. generate_appcast signs the new archive with the Keychain key, adds an entry,
# and keeps the entries already in the file — which is why the previous release's copy is
# brought down first. The release notes are the CHANGELOG entry.
echo "==> appcast"
[ -x "$GENERATE_APPCAST" ] || { echo "!! $GENERATE_APPCAST missing — resolve packages (xcodebuild)" >&2; exit 1; }
rm -rf "$FEED_DIR"; mkdir -p "$FEED_DIR"
cp "$ZIP" "$FEED_DIR/"
tools/changelog-notes.py "$VERSION" --html > "$FEED_DIR/Layland-$VERSION.html"
if gh release download -R "$REPO" -p appcast.xml -D "$FEED_DIR" 2>/dev/null; then
echo " previous appcast: $(grep -c '<item>' "$FEED_DIR/appcast.xml") entries"
else
echo " no previous release — starting a fresh appcast"
fi
APPCAST_FLAGS=()
[ "$CRITICAL" = yes ] && APPCAST_FLAGS+=(--critical-update-version "")
# The odd expansion is for macOS's bash 3.2, where an empty array is unset under `set -u`.
"$GENERATE_APPCAST" \
--download-url-prefix "$RELEASES/download/$TAG/" \
--link "https://layland.app" \
--embed-release-notes \
${APPCAST_FLAGS[@]+"${APPCAST_FLAGS[@]}"} \
"$FEED_DIR"
grep -q "sparkle:version>$BUILD<" "$FEED_DIR/appcast.xml" \
|| { echo "!! appcast has no entry for build $BUILD" >&2; exit 1; }
# The GitHub release: a draft with every asset, published only once they are all up. The
# tag is made here because the appcast points at a URL with the tag's name in it.
NOTES="$OUT/notes-$VERSION.md"
tools/changelog-notes.py "$VERSION" > "$NOTES"
cp "$DMG" "$STABLE_DMG"
if [ "$DRYRUN" = no ]; then
echo "==> tagging $TAG"
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
fi
echo "==> github release $TAG (draft)"
gh release create "$TAG" -R "$REPO" --draft --target "$(git rev-parse HEAD)" \
--title "Layland $VERSION" --notes-file "$NOTES" \
"$STABLE_DMG" "$ZIP" "$FEED_DIR/appcast.xml"
ASSETS=$(gh release view "$TAG" -R "$REPO" --json assets -q '.assets[].name')
for want in "$(basename "$STABLE_DMG")" "$(basename "$ZIP")" appcast.xml; do
grep -qx "$want" <<<"$ASSETS" || { echo "!! asset missing from the draft: $want" >&2; exit 1; }
done
echo " assets: $(tr '\n' ' ' <<<"$ASSETS")"
if [ "$DRYRUN" = yes ]; then
gh release delete "$TAG" -R "$REPO" --yes
echo
echo "dry run complete: the draft was created with all three assets and deleted again."
echo " $DMG is NOT notarized — do not ship this one"
exit 0
fi
echo "==> publishing"
gh release edit "$TAG" -R "$REPO" --draft=false --latest
# What every installed copy will see; GitHub takes a moment to point "latest" at it.
echo "==> checking the feed"
for attempt in $(seq 1 12); do
if curl -fsSL "$FEED_URL" | grep -q "sparkle:version>$BUILD<"; then
echo " $FEED_URL offers build $BUILD"
break
fi
[ "$attempt" = 12 ] && {
echo "!! $FEED_URL does not offer build $BUILD yet: either GitHub is slow to update 'latest'," >&2
echo " or layland.app's _redirects rule for /appcast.xml is not deployed." >&2
echo " Check: curl -sL $RELEASES/latest/download/appcast.xml | grep sparkle:version" >&2
exit 1
}
sleep 5
done
echo
echo "ready: $DMG"
echo " commit: $(git rev-parse --short HEAD)"
echo " release: $RELEASES/tag/$TAG"
echo " download: https://layland.app/download"
echo " feed: $FEED_URL — every copy that checks is offered $VERSION"