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
146 changes: 146 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Builds the macOS installer (.pkg) and publishes it as a GitHub Release asset.
#
# Trigger: push a version tag (git tag v0.2.0 && git push origin v0.2.0), or
# run it by hand with a version. The tag's number becomes the app's version;
# the run number becomes its build number.
#
# Signing is optional, so the pipeline works before the Apple prerequisites
# exist (docs/PREREQUISITES.md → Signing and distribution):
# - With the Developer ID secrets below, the .pkg is signed, notarized and
# stapled, and the release is a normal release.
# - Without them, the .pkg is unsigned (the app is ad-hoc signed) and the
# release is marked pre-release, for internal testing only.
#
# Secrets (repository settings → Secrets and variables → Actions):
# DEVELOPER_ID_APP_P12, DEVELOPER_ID_INSTALLER_P12 base64 of each exported .p12
# DEVELOPER_ID_P12_PASSWORD the .p12s' password
# NOTARY_KEY_P8 base64 of the App Store Connect API key (.p8)
# NOTARY_KEY_ID, NOTARY_ISSUER_ID that key's ID and issuer ID
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: Version to release, without the v (for example 0.2.0)
required: true

permissions:
contents: write # create the release and upload the .pkg

concurrency:
group: release
cancel-in-progress: false

jobs:
pkg:
runs-on: macos-15
env:
VERSION: ${{ inputs.version || github.ref_name }}
steps:
- uses: actions/checkout@v7

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Normalize the version
run: |
v="${VERSION#v}"
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { echo "::error::'$VERSION' isn't a version like 0.2.0"; exit 1; }
echo "VERSION=$v" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=${{ github.run_number }}" >> "$GITHUB_ENV"

- name: Install XcodeGen
run: brew install xcodegen

- name: Import signing certificates
id: certs
env:
APP_P12: ${{ secrets.DEVELOPER_ID_APP_P12 }}
INSTALLER_P12: ${{ secrets.DEVELOPER_ID_INSTALLER_P12 }}
P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
run: |
if [ -z "$APP_P12" ] || [ -z "$INSTALLER_P12" ]; then
echo "::warning::Developer ID certificates aren't configured; building an unsigned .pkg for testing."
echo "signed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
keychain="$RUNNER_TEMP/signing.keychain-db"
password="$(uuidgen)"
security create-keychain -p "$password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$password" "$keychain"
for name in APP INSTALLER; do
var="${name}_P12"
echo "${!var}" | base64 --decode > "$RUNNER_TEMP/$name.p12"
security import "$RUNNER_TEMP/$name.p12" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/productsign
rm "$RUNNER_TEMP/$name.p12"
done
security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" >/dev/null
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
app_id=$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: .*\)"/\1/p' | head -1)
installer_id=$(security find-identity -v "$keychain" | sed -n 's/.*"\(Developer ID Installer: .*\)"/\1/p' | head -1)
[ -n "$app_id" ] && [ -n "$installer_id" ] || { echo "::error::Couldn't find both Developer ID identities in the imported certificates"; exit 1; }
echo "APP_IDENTITY=$app_id" >> "$GITHUB_ENV"
echo "INSTALLER_IDENTITY=$installer_id" >> "$GITHUB_ENV"
echo "signed=true" >> "$GITHUB_OUTPUT"

- name: Set up notarization
if: steps.certs.outputs.signed == 'true'
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }}
run: |
if [ -z "$NOTARY_KEY_P8" ]; then
echo "::warning::NOTARY_KEY_P8 isn't configured; the .pkg will be signed but not notarized."
exit 0
fi
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
{
echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary.p8"
echo "NOTARY_KEY_ID=$NOTARY_KEY_ID"
echo "NOTARY_ISSUER_ID=$NOTARY_ISSUER_ID"
} >> "$GITHUB_ENV"

- name: Build the .pkg
id: build
run: |
pkg=$(scripts/build-pkg.sh | tail -1)
echo "pkg=$pkg" >> "$GITHUB_OUTPUT"

- name: Keep the archive log if the build failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: archive-log
path: build/pkg/archive.log
if-no-files-found: ignore

- name: Publish the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PKG: ${{ steps.build.outputs.pkg }}
SIGNED: ${{ steps.certs.outputs.signed }}
run: |
tag="v$VERSION"
notes=$(awk -v v="$VERSION" '
/^## / { if (found) exit; if ($2 == v || $2 == "Unreleased") found = 1; next }
found { print }' CHANGELOG.md)
if [ "$SIGNED" != "true" ]; then
notes=$(printf '> **Unsigned test build.** Not signed with Developer ID or notarized. To open it, right-click the .pkg and choose Open, or allow it in System Settings → Privacy & Security.\n\n%s' "$notes")
prerelease=--prerelease
fi
if gh release view "$tag" >/dev/null 2>&1; then
gh release upload "$tag" "$PKG" "$PKG.sha256" --clobber
else
gh release create "$tag" "$PKG" "$PKG.sha256" --title "Substratal Hub $VERSION" --notes "$notes" ${prerelease:-} \
$([ "${{ github.event_name }}" = workflow_dispatch ] && echo --target "$GITHUB_SHA")
fi

- name: Remove the signing keychain
if: always()
run: security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Every release of Substratal Hub for macOS. The Hub help center's What's new page
- Help menu: the Substratal Hub help center (shared by every Hub app), help for the current screen, the macOS guide, keyboard shortcuts, troubleshooting, FAQ, what's new, and the Platform API reference and versioning.
- Update notices: a banner when this version of the app is deprecated by the API, and a stop with "Update required" when the API no longer supports it.

### Build and release

- `scripts/run-local.sh`: build and launch the app for testing, against the production API or a local devserver.
- `scripts/build-pkg.sh` and the **Release** workflow: build the `.pkg` installer and publish it as a GitHub Release asset. Signed and notarized when the Developer ID secrets are configured; an unsigned pre-release otherwise.

### Platform API

- Built against API spec `1.0.0-draft.9` (API v1.8.0), version header `Substratal-Version: 1`. Paths have no `/v1` prefix.
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ build: project ## Build the app (Debug)
run: build ## Build and run; API=http://localhost:8080 points it at a local devserver
open $(APP) --args -SubstratalAPIBaseURL $(API)

run-local: ## Build and launch for testing; API=… to choose the API (default production)
scripts/run-local.sh $(API)

pkg: ## Build the installer into build/pkg (signs/notarizes when APP_IDENTITY etc. are set)
scripts/build-pkg.sh

test: ## SubstratalKit unit tests
cd Packages/SubstratalKit && swift test

Expand All @@ -38,4 +44,4 @@ help-check: ## Check the app's Help links against a fresh build of the Hub help

check: test build help-check ## Everything CI runs

.PHONY: help project build run test ui-test sync-api sync-screens help-check check
.PHONY: help project build run run-local pkg test ui-test sync-api sync-screens help-check check
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Requirements: Xcode 26 or later, macOS 15 or later, [XcodeGen](https://github.co
```bash
make project # generate SubstratalHub.xcodeproj
make build # or open SubstratalHub.xcodeproj and run the SubstratalHub scheme
scripts/run-local.sh # build and launch against the production API (or pass http://localhost:8080)
make run # build and launch against production
make test # SubstratalKit unit tests
make help-check # check the app's Help links against ../substratalapps-applications-specs
Expand All @@ -79,6 +80,17 @@ make run API=http://localhost:8080

or choose **Settings → Connection → Local devserver** in the app. Each environment keeps its own session. Sign in with the seeded `ADMIN_EMAIL` / `ADMIN_PASSWORD` from that repo's `.env`.

### Building the installer (.pkg)

```bash
scripts/build-pkg.sh # → build/pkg/SubstratalHub-<version>.pkg (+ .sha256)
VERSION=0.2.0 BUILD_NUMBER=7 scripts/build-pkg.sh
```

It archives the Release configuration and packages the app to install into `/Applications` (macOS 15 or later). With `APP_IDENTITY` and `INSTALLER_IDENTITY` (Developer ID) set, it signs both. With an App Store Connect key (`NOTARY_KEY_PATH`, `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID`) it also notarizes and staples. Without them it builds an unsigned test package.

**Releases on GitHub:** push a tag (`git tag v0.2.0 && git push origin v0.2.0`), or run the **Release** workflow by hand. `release.yml` builds the `.pkg` and attaches it and its checksum to a GitHub Release. Without the Developer ID secrets the release is an unsigned pre-release; the secrets are listed at the top of the workflow.

### Signing

Debug builds sign with your Apple Development certificate (team set in `project.yml`). Without a provisioning profile, the session token is stored in the login keychain instead of the data-protection Keychain. Release builds need the registered App ID and a Developer ID certificate; see [docs/PREREQUISITES.md](docs/PREREQUISITES.md).
Expand Down
4 changes: 3 additions & 1 deletion docs/PREREQUISITES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ In this app: the base URL is the host root (`APIEnvironment.baseURL`). `APIVersi
| Bundle ID `com.substratalapps.hub` | ⚠️ **decide** | Chosen here; nothing in the specs or the iOS repo fixes it yet. iOS must use the **same** ID (Universal Purchase), because `app_hub` has one APNs topic (CLIENT-ARCHITECTURE §2). Confirm, then register it as an App ID. |
| Registered App ID + provisioning profile | ❌ | The data-protection Keychain (device-only refresh token), Push Notifications and Associated Domains. Until then, a local build stores the session in the login keychain instead (`KeychainStore` falls back on `errSecMissingEntitlement`). |
| **Developer ID Application** certificate | ❌ | Notarized direct-download builds (the plan's distribution route). Only Apple Development is installed. |
| Notarization credentials (App Store Connect API key) | ❌ | `notarytool` in CI. Store as GitHub secrets. |
| **Developer ID Installer** certificate | ❌ | Signing the `.pkg` (`INSTALLER_IDENTITY` / `DEVELOPER_ID_INSTALLER_P12`). |
| Notarization credentials (App Store Connect API key) | ❌ | `notarytool` in `release.yml`. Store as the secrets `NOTARY_KEY_P8`, `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID`. |
| `.pkg` build | ✅ | `scripts/build-pkg.sh` builds `SubstratalHub-<version>.pkg` (Release archive, installs to `/Applications`, macOS 15+), verified locally on 2026-10-23. `release.yml` publishes it as a GitHub Release asset on a `v*` tag: unsigned pre-release until the Developer ID secrets are set, then signed, notarized and stapled. |
| Sparkle update feed host (`downloads.substratalapps.com`) | ❌ | Doesn't resolve. Can start as a GitHub Release asset plus an appcast on Pages. |

## Repositories and publishing
Expand Down
108 changes: 108 additions & 0 deletions scripts/build-pkg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Builds the distributable installer: build/pkg/SubstratalHub-<version>.pkg
#
# scripts/build-pkg.sh # version from project.yml, build number 1
# VERSION=0.2.0 BUILD_NUMBER=7 scripts/build-pkg.sh
#
# Release-configuration archive → app installed to /Applications by a
# component package → product archive (.pkg). How far it goes depends on what's
# available, so the same script serves a laptop and the release workflow:
#
# APP_IDENTITY "Developer ID Application: …" signs the app (hardened runtime, timestamp).
# Unset → ad-hoc signed: runs locally, Gatekeeper warns elsewhere.
# INSTALLER_IDENTITY "Developer ID Installer: …" signs the .pkg. Unset → unsigned .pkg.
# NOTARY_KEY_PATH, NOTARY_KEY_ID, NOTARY_ISSUER_ID
# App Store Connect API key → notarize and staple the .pkg.
# Needs both identities. Unset → not notarized.
#
# Prints the .pkg path last, and writes <pkg>.sha256 next to it.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"

VERSION="${VERSION:-$(awk '/MARKETING_VERSION:/ {gsub(/"/, "", $2); print $2; exit}' project.yml)}"
BUILD_NUMBER="${BUILD_NUMBER:-1}"
APP_IDENTITY="${APP_IDENTITY:-}"
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
PKG_ID="com.substratalapps.hub.pkg"
OUT="$ROOT/build/pkg"
ARCHIVE="$OUT/SubstratalHub.xcarchive"
PKG="$OUT/SubstratalHub-$VERSION.pkg"

step() { echo "==> $*"; }

rm -rf "$OUT"
mkdir -p "$OUT"

step "Generating the Xcode project"
xcodegen generate --quiet

signing=(CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= PROVISIONING_PROFILE_SPECIFIER=)
if [[ -n "$APP_IDENTITY" ]]; then
signing+=("CODE_SIGN_IDENTITY=$APP_IDENTITY" "OTHER_CODE_SIGN_FLAGS=--timestamp")
# The team comes from the identity, e.g. "Developer ID Application: Name (TEAMID)".
team="$(sed -n 's/.*(\([A-Z0-9]\{10\}\))$/\1/p' <<<"$APP_IDENTITY")"
[[ -n "$team" ]] && signing+=("DEVELOPMENT_TEAM=$team")
step "Archiving $VERSION ($BUILD_NUMBER), signed by $APP_IDENTITY"
else
signing+=("CODE_SIGN_IDENTITY=-")
step "Archiving $VERSION ($BUILD_NUMBER), ad-hoc signed (no APP_IDENTITY)"
fi

log="$OUT/archive.log"
if ! xcodebuild archive \
-project SubstratalHub.xcodeproj -scheme SubstratalHub -configuration Release \
-destination 'generic/platform=macOS' -archivePath "$ARCHIVE" \
-derivedDataPath "$ROOT/build/DerivedData-Release" \
-skipPackagePluginValidation -skipMacroValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
"${signing[@]}" >"$log" 2>&1; then
grep -E 'error:' "$log" | sort -u >&2 || true
echo "build-pkg: archive failed; full log at $log" >&2
exit 1
fi

APP="$ARCHIVE/Products/Applications/Substratal Hub.app"
[[ -d "$APP" ]] || { echo "build-pkg: $APP not found in the archive" >&2; exit 1; }
codesign --verify --strict --deep "$APP"
step "App signature: $(codesign -dvv "$APP" 2>&1 | sed -n 's/^Authority=//p' | head -1 || true)$( [[ -z "$APP_IDENTITY" ]] && echo 'ad-hoc')"

step "Building the component package"
root="$OUT/root"
mkdir -p "$root"
ditto "$APP" "$root/Substratal Hub.app"
plist="$OUT/component.plist"
pkgbuild --analyze --root "$root" "$plist" >/dev/null
# Always install to /Applications, never "update" a copy found elsewhere.
plutil -replace 0.BundleIsRelocatable -bool NO "$plist"
pkgbuild --root "$root" --component-plist "$plist" --install-location /Applications \
--identifier "$PKG_ID" --version "$VERSION" "$OUT/component.pkg" >/dev/null

step "Building the installer"
dist="$OUT/distribution.xml"
productbuild --synthesize --package "$OUT/component.pkg" "$dist" >/dev/null
# Title, and the minimum macOS the app supports.
sed -i '' "s#<installer-gui-script minSpecVersion=\"[0-9]*\">#&\n <title>Substratal Hub $VERSION</title>\n <allowed-os-versions><os-version min=\"15.0\"/></allowed-os-versions>#" "$dist"
sign=()
[[ -n "$INSTALLER_IDENTITY" ]] && sign=(--sign "$INSTALLER_IDENTITY" --timestamp)
productbuild --distribution "$dist" --package-path "$OUT" ${sign[@]+"${sign[@]}"} "$PKG" >/dev/null

if [[ -n "${NOTARY_KEY_PATH:-}" && -n "${NOTARY_KEY_ID:-}" && -n "${NOTARY_ISSUER_ID:-}" ]]; then
[[ -n "$APP_IDENTITY" && -n "$INSTALLER_IDENTITY" ]] || { echo "build-pkg: notarizing needs APP_IDENTITY and INSTALLER_IDENTITY" >&2; exit 1; }
step "Notarizing (this waits for Apple)"
xcrun notarytool submit "$PKG" --key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait
xcrun stapler staple "$PKG"
spctl --assess --type install --verbose "$PKG"
status="signed, notarized and stapled"
elif [[ -n "$INSTALLER_IDENTITY" ]]; then
pkgutil --check-signature "$PKG" | head -3
status="signed, not notarized"
else
status="unsigned (for testing: right-click → Open, or allow it in System Settings → Privacy & Security)"
fi

rm -rf "$root" "$OUT/component.pkg" "$plist" "$dist"
(cd "$OUT" && shasum -a 256 "$(basename "$PKG")" >"$(basename "$PKG").sha256")
step "Built $(basename "$PKG"): $status"
echo "$PKG"
52 changes: 52 additions & 0 deletions scripts/run-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Builds Substratal Hub (Debug) and launches it for local testing.
#
# scripts/run-local.sh # against the production API, https://api.substratalapps.com
# scripts/run-local.sh http://localhost:8080 # against the API repo's local devserver (make run there)
#
# Generates the Xcode project when project.yml is newer (or it's missing),
# quits a running copy first, checks the API answers /health, then opens the
# app with -SubstratalAPIBaseURL so this run uses that API without changing
# the app's saved setting. Sign in with your own Substratal account.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
API="${1:-https://api.substratalapps.com}"
API="${API%/}"
DERIVED="$ROOT/build/DerivedData"
APP="$DERIVED/Build/Products/Debug/Substratal Hub.app"

cd "$ROOT"

command -v xcodegen >/dev/null || { echo "run-local: XcodeGen is required: brew install xcodegen" >&2; exit 1; }
if [[ ! -d SubstratalHub.xcodeproj || project.yml -nt SubstratalHub.xcodeproj ]]; then
echo "==> Generating SubstratalHub.xcodeproj"
xcodegen generate --quiet
fi

echo "==> Checking $API/health"
if ! curl -fsS -m 10 "$API/health" >/dev/null; then
echo "run-local: $API/health didn't answer. Is the API running and the URL right? (No /v1: the version is a header now.)" >&2
exit 1
fi

echo "==> Building (Debug). The first build takes a few minutes: it generates the API client."
log="$(mktemp -t substratal-build)"
if ! xcodebuild -project SubstratalHub.xcodeproj -scheme SubstratalHub -configuration Debug \
-destination 'platform=macOS' -derivedDataPath "$DERIVED" \
-skipPackagePluginValidation -skipMacroValidation build >"$log" 2>&1; then
grep -E 'error:' "$log" | sort -u >&2 || true
echo "run-local: build failed; full log at $log" >&2
exit 1
fi
rm -f "$log"

if pgrep -x "Substratal Hub" >/dev/null; then
echo "==> Quitting the running copy"
osascript -e 'quit app "Substratal Hub"' >/dev/null 2>&1 || pkill -x "Substratal Hub" || true
for _ in {1..20}; do pgrep -x "Substratal Hub" >/dev/null || break; sleep 0.25; done
fi

echo "==> Launching against $API"
open -n "$APP" --args -SubstratalAPIBaseURL "$API"
echo "Done. App logs: log stream --predicate 'subsystem == \"com.substratalapps.hub\"'"
Loading