From be7fcdf4e6c957803b3cba73f83cb14ee2901371 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 21:23:41 -0700 Subject: [PATCH] Add run-local.sh, a .pkg build, and a Release workflow that publishes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/run-local.sh: generate the project if needed, check the API's /health, build Debug and launch against the production API (or a URL given, such as a local devserver) for this run only. - scripts/build-pkg.sh: Release archive → component package installing to /Applications (not relocatable, macOS 15+) → SubstratalHub-.pkg and a .sha256. Signs the app and the .pkg with Developer ID, and notarizes and staples, when the identities and an App Store Connect key are given; otherwise an ad-hoc signed app in an unsigned .pkg for testing. - .github/workflows/release.yml: on a v* tag (or by hand), imports the Developer ID certificates if configured, builds the .pkg and publishes it with its checksum as a GitHub Release asset. Unsigned builds are marked pre-release. - make run-local / make pkg; README, CHANGELOG and PREREQUISITES updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 146 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 5 ++ Makefile | 8 +- README.md | 12 +++ docs/PREREQUISITES.md | 4 +- scripts/build-pkg.sh | 108 +++++++++++++++++++++++++ scripts/run-local.sh | 52 ++++++++++++ 7 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/build-pkg.sh create mode 100755 scripts/run-local.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..752f0c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8e043..d7ff3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile index d121c83..45486f4 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/README.md b/README.md index f46f4ec..153461e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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-.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). diff --git a/docs/PREREQUISITES.md b/docs/PREREQUISITES.md index 505425f..175c0c1 100644 --- a/docs/PREREQUISITES.md +++ b/docs/PREREQUISITES.md @@ -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-.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 diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh new file mode 100755 index 0000000..d92b48f --- /dev/null +++ b/scripts/build-pkg.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Builds the distributable installer: build/pkg/SubstratalHub-.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 .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##&\n Substratal Hub $VERSION\n #" "$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" diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..821b501 --- /dev/null +++ b/scripts/run-local.sh @@ -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\"'"