Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions .github/workflows/macos-app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ on:
required: false
type: string
default: Support/Info.plist
app-extensions:
description: >-
App extensions to embed, one per line as `<executable> <info-plist> <entitlements>`,
e.g. `SitrepWidget Support/Widget-Info.plist Support/SitrepWidget.entitlements`. Each
SwiftPM executable becomes Contents/PlugIns/<executable>.appex, takes the app's
version, and is signed with its own entitlements before the app is.
required: false
type: string
default: ''
icon:
description: .icns to bundle as Contents/Resources (skipped if absent)
required: false
Expand Down Expand Up @@ -97,6 +106,7 @@ jobs:
EXECUTABLE: ${{ inputs.executable }}
BUNDLE_ID: ${{ inputs.bundle-id }}
ENTITLEMENTS: ${{ inputs.entitlements }}
APP_EXTENSIONS: ${{ inputs.app-extensions }}
INFO_PLIST: ${{ inputs.info-plist }}
ICON: ${{ inputs.icon }}
DRY_RUN: ${{ inputs.dry-run }}
Expand Down Expand Up @@ -127,6 +137,19 @@ jobs:
[[ "$(plist_get CFBundleExecutable)" == "$EXECUTABLE" ]] \
|| { echo "::error::CFBundleExecutable in $INFO_PLIST is not $EXECUTABLE"; exit 1; }

while read -r ext_exe ext_plist ext_entitlements extra; do
[[ -z "$ext_exe" ]] && continue
if [[ -z "$ext_entitlements" || -n "$extra" ]]; then
echo "::error::app-extensions lines are '<executable> <info-plist> <entitlements>': got '$ext_exe $ext_plist $ext_entitlements $extra'"; exit 1
fi
test -f "$ext_plist" || { echo "::error::Extension Info.plist not found: $ext_plist"; exit 1; }
test -f "$ext_entitlements" || { echo "::error::Extension entitlements not found: $ext_entitlements"; exit 1; }
[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$ext_plist")" == "$ext_exe" ]] \
|| { echo "::error::CFBundleExecutable in $ext_plist is not $ext_exe"; exit 1; }
/usr/libexec/PlistBuddy -c 'Print :NSExtension:NSExtensionPointIdentifier' "$ext_plist" > /dev/null \
|| { echo "::error::$ext_plist has no NSExtension:NSExtensionPointIdentifier"; exit 1; }
done <<< "$APP_EXTENSIONS"

if [[ "$GITHUB_REF_TYPE" == tag ]]; then
[[ "$GITHUB_REF_NAME" == v* ]] || { echo "::error::Tag $GITHUB_REF_NAME must start with v (v1.2.3)"; exit 1; }
version="${GITHUB_REF_NAME#v}"
Expand Down Expand Up @@ -168,11 +191,21 @@ jobs:
run: |
set -euo pipefail
swift build -c release --arch arm64
bin=".build/arm64-apple-macosx/release/$EXECUTABLE"
test -x "$bin" || { echo "::error::Release binary not found at $bin"; exit 1; }
archs="$(lipo -archs "$bin")"
echo "Architectures: $archs"
[[ "$archs" == "arm64" ]] || { echo "::error::Expected arm64 only, got: $archs"; exit 1; }
# Ask SwiftPM where it put the products: the layout differs by toolchain.
bin_dir="$(swift build -c release --arch arm64 --show-bin-path)"
echo "BIN_DIR=$bin_dir" >> "$GITHUB_ENV"
check_binary() {
local bin="$bin_dir/$1" archs
test -x "$bin" || { echo "::error::Release binary not found at $bin"; exit 1; }
archs="$(lipo -archs "$bin")"
echo "$1 architectures: $archs"
[[ "$archs" == "arm64" ]] || { echo "::error::Expected arm64 only for $1, got: $archs"; exit 1; }
}
check_binary "$EXECUTABLE"
while read -r ext_exe _; do
[[ -z "$ext_exe" ]] && continue
check_binary "$ext_exe"
done <<< "$APP_EXTENSIONS"

- name: Assemble and stamp the .app
env:
Expand All @@ -183,13 +216,25 @@ jobs:
app="$DIST/$APP_NAME.app"
rm -rf "$DIST"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp ".build/arm64-apple-macosx/release/$EXECUTABLE" "$app/Contents/MacOS/$EXECUTABLE"
cp "$BIN_DIR/$EXECUTABLE" "$app/Contents/MacOS/$EXECUTABLE"
cp "$INFO_PLIST" "$app/Contents/Info.plist"
if [[ -f "$ICON" ]]; then cp "$ICON" "$app/Contents/Resources/"; fi
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD" "$app/Contents/Info.plist"
plutil -lint "$app/Contents/Info.plist"

# Extensions must carry their host app's version, or the system refuses to load them.
while read -r ext_exe ext_plist _; do
[[ -z "$ext_exe" ]] && continue
appex="$app/Contents/PlugIns/$ext_exe.appex"
mkdir -p "$appex/Contents/MacOS"
cp "$BIN_DIR/$ext_exe" "$appex/Contents/MacOS/$ext_exe"
cp "$ext_plist" "$appex/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$appex/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD" "$appex/Contents/Info.plist"
Comment on lines +233 to +234
plutil -lint "$appex/Contents/Info.plist"
done <<< "$APP_EXTENSIONS"

- name: Import Developer ID certificates
if: env.HAS_APP_CERT == 'true'
env:
Expand Down Expand Up @@ -237,6 +282,11 @@ jobs:
run: |
set -euo pipefail
args=(--force --options runtime --timestamp --keychain "$KEYCHAIN" --sign "$APP_IDENTITY")
# Inside out: each extension with its own entitlements, then the app seals them.
while read -r ext_exe _ ext_entitlements; do
[[ -z "$ext_exe" ]] && continue
codesign "${args[@]}" --entitlements "$ext_entitlements" "$DIST/$APP_NAME.app/Contents/PlugIns/$ext_exe.appex"
done <<< "$APP_EXTENSIONS"
if [[ -n "$ENTITLEMENTS" ]]; then args+=(--entitlements "$ENTITLEMENTS"); fi
codesign "${args[@]}" "$DIST/$APP_NAME.app"

Expand All @@ -245,14 +295,18 @@ jobs:
run: |
set -euo pipefail
args=(--force --options runtime --sign -)
while read -r ext_exe _ ext_entitlements; do
[[ -z "$ext_exe" ]] && continue
codesign "${args[@]}" --entitlements "$ext_entitlements" "$DIST/$APP_NAME.app/Contents/PlugIns/$ext_exe.appex"
done <<< "$APP_EXTENSIONS"
if [[ -n "$ENTITLEMENTS" ]]; then args+=(--entitlements "$ENTITLEMENTS"); fi
codesign "${args[@]}" "$DIST/$APP_NAME.app"
echo "::warning title=Unsigned build::$APP_NAME is ad-hoc signed and not notarised (no DEVELOPER_ID_APP_P12_BASE64). Gatekeeper will block it on other Macs and Intune will not accept the .pkg."

- name: Verify signature
run: |
set -euo pipefail
codesign --verify --strict --verbose=2 "$DIST/$APP_NAME.app"
codesign --verify --strict --deep --verbose=2 "$DIST/$APP_NAME.app"
codesign -dvv "$DIST/$APP_NAME.app" 2>&1 | grep -E '^(Identifier|Authority|TeamIdentifier|Timestamp|Runtime Version|CodeDirectory)'

- name: Notarise and staple the .app
Expand Down
25 changes: 24 additions & 1 deletion docs/releasing-macos-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ reusable workflow in this repo,
1. `swift test`
2. arm64 build (`swift build -c release --arch arm64`)
3. assemble `<App>.app`, stamping `CFBundleShortVersionString` from the tag
(`v1.2.3` → `1.2.3`) and `CFBundleVersion` from the run number
(`v1.2.3` → `1.2.3`) and `CFBundleVersion` from the run number, with any
app extensions (see below) in `Contents/PlugIns/`
4. sign with hardened runtime, notarise and staple, if the Developer ID secrets
are present; ad-hoc sign and warn if they aren't
5. `<App>-<version>.zip`, plus `<App>-<version>.pkg` installing to
Expand All @@ -39,6 +40,28 @@ Signing turns on by itself: once the secrets below are on an app repo's
change. A half-configured environment (a certificate without its password or
API key) fails the run instead of shipping something half-signed.

### App extensions (widgets)

SwiftPM has no app-extension product type, so an extension such as a WidgetKit
widget is built as an ordinary executable target, linked the way Xcode links
extensions (`-e _NSExtensionMain`, `-application_extension`). The workflow
wraps it into a bundle when the caller lists it in `app-extensions`, one per
line as `<executable> <info-plist> <entitlements>`:

```yaml
with:
app-extensions: |
SitrepWidget Support/Widget-Info.plist Support/SitrepWidget.entitlements
```

Each one becomes `Contents/PlugIns/<executable>.appex`, takes the app's
version and build number (the system won't load an extension whose version
differs from its host app's), and is signed with its own entitlements before
the app is signed over it. WidgetKit only loads sandboxed extensions, so the
entitlements need at least `com.apple.security.app-sandbox`. The Info.plist
must name the executable and carry `NSExtension` → `NSExtensionPointIdentifier`;
the run checks both before building.

## Secrets

| GitHub `release` environment secret | Key Vault secret | Contents |
Expand Down
Loading