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
14 changes: 14 additions & 0 deletions .github/scripts/archive-unsigned-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail

archive_path="${1:?archive path required}"
version_name="${2:?marketing version required}"
build_number="${3:?build number required}"

# Releases and PR validation use this same command so a tag's version reaches the binary.
xcodebuild archive -project SwiftRadio.xcodeproj -scheme SwiftRadio \
-configuration Release -destination 'generic/platform=iOS' \
-archivePath "$archive_path" \
-onlyUsePackageVersionsFromResolvedFile -jobs 2 \
CODE_SIGNING_ALLOWED=NO \
MARKETING_VERSION="$version_name" CURRENT_PROJECT_VERSION="$build_number"
52 changes: 52 additions & 0 deletions .github/scripts/package-unsigned-app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Package and verify the same unsigned phone IPA in PR CI and releases."""

import argparse
from pathlib import Path
import plistlib
import shutil
import subprocess
import tempfile
import zipfile

parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("version")
parser.add_argument("build")
args = parser.parse_args()
app = args.archive / "Products/Applications/SwiftRadio.app"
info = plistlib.loads((app / "Info.plist").read_bytes())
expected = {"CFBundleIdentifier": "com.matthewfecher.SwiftRadio",
"CFBundleShortVersionString": args.version, "CFBundleVersion": args.build}
for key, value in expected.items():
if info.get(key) != value:
raise SystemExit(f"Archive {key}: expected {value}, got {info.get(key)}")
binary = app / info["CFBundleExecutable"]
architectures = subprocess.check_output(["lipo", "-archs", str(binary)], text=True).split()
if architectures != ["arm64"]:
raise SystemExit(f"Expected device arm64 binary, got {architectures}")
framework = app / "Frameworks/LNPopupUI.framework/LNPopupUI"
if not framework.is_file():
raise SystemExit("Archive is missing the embedded LNPopupUI framework")
linkage = subprocess.check_output(["otool", "-L", str(binary)], text=True)
if "@rpath/LNPopupUI.framework/LNPopupUI" not in linkage:
raise SystemExit("App does not link its embedded LNPopupUI framework")
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
raise SystemExit(f"Refusing to replace existing package: {output}")
with tempfile.TemporaryDirectory(prefix="swiftradio-package-") as scratch:
payload = Path(scratch) / "Payload"
payload.mkdir()
shutil.copytree(app, payload / app.name, symlinks=True)
subprocess.run(["zip", "-qry", str(output), "Payload"], cwd=scratch, check=True)
with zipfile.ZipFile(output) as package:
if package.testzip() is not None:
raise SystemExit("IPA failed its ZIP integrity check")
prefix = "Payload/SwiftRadio.app/"
packaged_info = plistlib.loads(package.read(prefix + "Info.plist"))
if any(packaged_info.get(key) != value for key, value in expected.items()):
raise SystemExit("Packaged version differs from the archive")
package.getinfo(prefix + "Frameworks/LNPopupUI.framework/LNPopupUI")
package.getinfo(prefix + info["CFBundleExecutable"])
print(f"Verified unsigned device IPA: {output.name} ({args.version}, build {args.build})")
15 changes: 15 additions & 0 deletions .github/scripts/select-simulator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Select an installed iPhone simulator for the pinned Xcode 27 test job."""

import json
import subprocess

devices = json.loads(subprocess.check_output(
["xcrun", "simctl", "list", "devices", "available", "--json"], text=True
))["devices"]
candidates = [device for runtime, entries in devices.items()
if runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-27-")
for device in entries if device["name"].startswith("iPhone")]
if not candidates:
raise SystemExit("No installed iOS 27 iPhone simulator; check the runner image.")
candidates.sort(key=lambda device: (device["name"] != "iPhone 18 Pro", device["name"]))
print(candidates[0]["udid"])
62 changes: 62 additions & 0 deletions .github/workflows/archive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Archive

on:
push:
branches: [master, dev]
pull_request:
branches: [master, dev]

permissions:
contents: read

jobs:
archive:
name: Unsigned archive and IPA (${{ matrix.version_source }}, Xcode 26.6)
runs-on: macos-26
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
version_source: [project, next-tag]
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.6'
- name: Read project version
env:
VERSION_SOURCE: ${{ matrix.version_source }}
run: |
xcodebuild -showBuildSettings -json -project SwiftRadio.xcodeproj \
-scheme SwiftRadio -configuration Release > "$RUNNER_TEMP/settings.json"
python3 - "$RUNNER_TEMP/settings.json" >> "$GITHUB_ENV" <<'PY'
import json, os, sys
settings = next(item['buildSettings'] for item in json.load(open(sys.argv[1])) if item['target'] == 'SwiftRadio')
version, build = settings['MARKETING_VERSION'], settings['CURRENT_PROJECT_VERSION']
if os.environ['VERSION_SOURCE'] == 'next-tag':
# Exercise a tag version different from the checked-in project without creating a tag.
major, minor, patch = map(int, version.split('.'))
patch += 1
version = f'{major}.{minor}.{patch}'
build = str(major * 10000 + minor * 100 + patch)
print('VERSION_NAME=' + version)
print('BUILD_NUMBER=' + build)
PY
- name: Build unsigned Release archive
run: |
set -o pipefail
mkdir -p artifacts
bash .github/scripts/archive-unsigned-app.sh \
"$RUNNER_TEMP/SwiftRadio.xcarchive" "$VERSION_NAME" "$BUILD_NUMBER" \
2>&1 | tee artifacts/archive.log
- name: Package and validate IPA
run: |
python3 .github/scripts/package-unsigned-app.py \
"$RUNNER_TEMP/SwiftRadio.xcarchive" artifacts/SwiftRadio-unsigned.ipa \
"$VERSION_NAME" "$BUILD_NUMBER"
- uses: actions/upload-artifact@v4
if: always()
with:
name: unsigned-archive-validation-${{ matrix.version_source }}
path: artifacts/
retention-days: 14
37 changes: 14 additions & 23 deletions .github/workflows/carplay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ name: CarPlay build

on:
push:
branches: [ "master", "dev" ]
branches: [master, dev]
pull_request:
branches: [ "master", "dev" ]
branches: [master, dev]

permissions:
contents: read

jobs:
build:
name: Build SwiftRadio-CarPlay (Xcode ${{ matrix.xcode }})
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
Expand All @@ -18,32 +22,19 @@ jobs:
- runner: xcode-27
xcode: '27.0'
runs-on: ${{ matrix.runner }}

steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}
- name: Verify toolchain
run: |
xcodebuild -version
xcodebuild -showsdks
swift --version
- name: Set Default Scheme
run: |
scheme_list=$(xcodebuild -list -json | tr -d "\n")
default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][1]")
echo $default | cat >default
echo Using default scheme: $default
- name: Build
env:
scheme: ${{ 'default' }}
- name: Build for testing
run: |
# Generic simulator destination: no dependency on which simulator
# runtimes the runner image ships (device-name matching is flaky)
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "generic/platform=iOS Simulator"
xcodebuild build-for-testing \
-project SwiftRadio.xcodeproj -scheme SwiftRadio-CarPlay \
-destination 'generic/platform=iOS Simulator' \
-onlyUsePackageVersionsFromResolvedFile -jobs 2 \
CODE_SIGNING_ALLOWED=NO
49 changes: 14 additions & 35 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ name: iOS build

on:
push:
branches: [ "master", "dev" ]
branches: [master, dev]
pull_request:
branches: [ "master", "dev" ]
branches: [master, dev]

permissions:
contents: read

jobs:
build:
name: Build SwiftRadio (Xcode ${{ matrix.xcode }})
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
Expand All @@ -18,44 +22,19 @@ jobs:
- runner: xcode-27
xcode: '27.0'
runs-on: ${{ matrix.runner }}

steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}
- name: Verify toolchain
run: |
xcodebuild -version
xcodebuild -showsdks
swift --version
- name: Set Default Scheme
run: |
scheme_list=$(xcodebuild -list -json | tr -d "\n")
default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]")
echo $default | cat >default
echo Using default scheme: $default
- name: Build
env:
scheme: ${{ 'default' }}
- name: Build for testing
run: |
# Generic simulator destination: no dependency on which simulator
# runtimes the runner image ships (device-name matching is flaky)
if [ $scheme = default ]; then scheme=$(cat default); fi
if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
xcodebuild build-for-testing -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "generic/platform=iOS Simulator"
# Disable testing for now
# - name: Test
# env:
# scheme: ${{ 'default' }}
# platform: ${{ 'iOS Simulator' }}
# run: |
# # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959)
# device=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"`
# if [ $scheme = default ]; then scheme=$(cat default); fi
# if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi
# file_to_build=`echo $file_to_build | awk '{$1=$1;print}'`
# xcodebuild test-without-building -scheme "$scheme" -"$filetype_parameter" "$file_to_build" -destination "platform=$platform,name=$device"
xcodebuild build-for-testing \
-project SwiftRadio.xcodeproj -scheme SwiftRadio \
-destination 'generic/platform=iOS Simulator' \
-onlyUsePackageVersionsFromResolvedFile -jobs 2 \
CODE_SIGNING_ALLOWED=NO
59 changes: 22 additions & 37 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
name: Release

# Pushing a tag like v3.1.0 builds the app, packages an unsigned .ipa,
# and creates a GitHub release with generated notes. No signing identity
# or Apple developer account is required: the .ipa is unsigned and meant
# for sideloading (AltStore, Sideloadly), where the installer tool
# re-signs it with the user's own Apple ID.

# A reviewed version tag on master packages an unsigned IPA and publishes
# the corresponding versioned notes. PRs only run archive validation.
on:
push:
tags: ["v*"]
Expand All @@ -16,48 +12,37 @@ permissions:
jobs:
release:
runs-on: macos-26

timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
- uses: actions/checkout@v4
with:
xcode-version: latest-stable

# Tags must be vMAJOR.MINOR.PATCH. Build number packs the tag into a
# monotonically increasing integer: v3.1.0 -> 30100.
- name: Derive version from tag
fetch-depth: 0
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.6'
- name: Validate release tag and notes
run: |
[[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
git merge-base --is-ancestor "$GITHUB_SHA" origin/master
VERSION_NAME="${GITHUB_REF_NAME#v}"
IFS=. read -r MAJOR MINOR PATCH <<< "$VERSION_NAME"
BUILD_NUMBER=$((MAJOR * 10000 + MINOR * 100 + ${PATCH:-0}))
BUILD_NUMBER=$((MAJOR * 10000 + MINOR * 100 + PATCH))
test -s "Documentation/Releases/$VERSION_NAME.md"
echo "VERSION_NAME=$VERSION_NAME" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"

- name: Build unsigned archive
- name: Build unsigned Release archive
run: |
xcodebuild archive \
-project SwiftRadio.xcodeproj \
-scheme SwiftRadio \
-destination 'generic/platform=iOS' \
-archivePath build/SwiftRadio.xcarchive \
CODE_SIGNING_ALLOWED=NO \
MARKETING_VERSION="$VERSION_NAME" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER"

- name: Package unsigned IPA
bash .github/scripts/archive-unsigned-app.sh \
build/SwiftRadio.xcarchive "$VERSION_NAME" "$BUILD_NUMBER"
- name: Package and validate unsigned IPA
run: |
mkdir -p Payload
cp -R "build/SwiftRadio.xcarchive/Products/Applications/SwiftRadio.app" Payload/
zip -qr "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" Payload

python3 .github/scripts/package-unsigned-app.py \
build/SwiftRadio.xcarchive "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" \
"$VERSION_NAME" "$BUILD_NUMBER"
- name: Create GitHub release
run: |
gh release create "$GITHUB_REF_NAME" "swift-radio-$GITHUB_REF_NAME-unsigned.ipa" \
--title "Swift Radio $GITHUB_REF_NAME" \
--generate-notes \
--notes "The attached \`.ipa\` is unsigned. Install it by sideloading with [AltStore](https://altstore.io) or [Sideloadly](https://sideloadly.io), which re-sign it with your own Apple ID. Or clone the repo and run it from Xcode with your personal team."
--verify-tag --title "Swift Radio $GITHUB_REF_NAME" \
--notes-file "Documentation/Releases/$VERSION_NAME.md"
env:
GH_TOKEN: ${{ github.token }}
Loading
Loading