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
296 changes: 296 additions & 0 deletions .github/workflows/release-macos-pkg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
name: macOS PKG + .mobileconfig

on:
push:
tags: ['v*']
pull_request:
paths:
- 'packaging/macos/**'
- '.github/workflows/release-macos-pkg.yml'
- 'cmd/proxy/**'
- 'internal/ca/**'
- 'internal/mitm/**'
- 'Makefile'

permissions:
contents: read

# Tagged releases produce signed artifacts and a GitHub release — running two
# at once would race the upload. PR runs can overlap by head SHA.
concurrency:
group: macos-pkg-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Build (macos-latest)
runs-on: macos-latest
permissions:
contents: read
env:
# Apple rejects re-submission of identical content for notarization, and
# the 10 signing secrets are a release-time concern. PR runs validate
# compilation + script + plist syntax; tag runs sign + notarize + upload.
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true

# PR-event coverage. Runs the actual build scripts unsigned — exercises
# payload staging, lipo, pkgbuild, productbuild, distribution.xml
# substitution, mobileconfig template rendering, base64 CA encoding,
# and plutil-lint. Catches mechanical bugs that the lint-only path
# missed (PR #123 review N1). The resulting artifacts are unsigned
# and cannot be installed; they're discarded after the step.
- name: Validate build mechanism (PR dry-run, no signing)
if: env.IS_RELEASE != 'true'
run: |
set -euo pipefail
# Static checks first — fast fail on syntax issues.
for s in packaging/macos/pkg/build.sh \
packaging/macos/pkg/notarize.sh \
packaging/macos/pkg/scripts/postinstall \
packaging/macos/pkg/scripts/preuninstall \
packaging/macos/mobileconfig/build.sh; do
bash -n "$s"
done
plutil -lint packaging/macos/pkg/com.ai-anonymizing-proxy.plist
xmllint --noout packaging/macos/pkg/distribution.xml
xmllint --noout packaging/macos/mobileconfig/ai-proxy.mobileconfig.tmpl

# Generate a throwaway CA so mobileconfig/build.sh has something
# to base64-encode into the certificate payload. This CA never
# leaves the runner and is not used for signing — it is the
# payload contents only.
mkdir -p build/dry-run-ca
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=ai-proxy PR dry-run (NOT FOR USE)" \
-keyout build/dry-run-ca/ca-key.pem \
-out build/dry-run-ca/ca-cert.pem >/dev/null 2>&1
chmod 0600 build/dry-run-ca/ca-key.pem

# build.sh / mobileconfig/build.sh read VERSION directly; the
# Makefile-level PKG_VERSION wraps it. Call the scripts with
# VERSION set explicitly since we're bypassing make here.
VERSION=0.0.0-dryrun \
SKIP_SIGN=1 \
bash packaging/macos/pkg/build.sh

VERSION=0.0.0-dryrun \
CA_CERT="$PWD/build/dry-run-ca/ca-cert.pem" \
SKIP_SIGN=1 \
bash packaging/macos/mobileconfig/build.sh

# Verify the artifacts the build path produced.
test -f dist/ai-proxy-0.0.0-dryrun-universal.pkg
test -f dist/ai-proxy-0.0.0-dryrun.mobileconfig
# Distribution PKG's component payload list should mention the binary
# and the LaunchDaemon plist — quickest mechanical structural check.
# pkgutil --payload-files emits paths with a leading './' (relative
# to the install location), so grep does not anchor on '^/'.
payload=$(pkgutil --payload-files dist/ai-proxy-0.0.0-dryrun-universal.pkg)
echo "$payload" | grep -qE 'usr/local/bin/ai-proxy$' \
|| { echo "::error::PKG payload missing ai-proxy binary"; echo "$payload"; exit 1; }
echo "$payload" | grep -qE 'Library/LaunchDaemons/com\.ai-anonymizing-proxy\.plist$' \
|| { echo "::error::PKG payload missing LaunchDaemon plist"; echo "$payload"; exit 1; }
plutil -lint dist/ai-proxy-0.0.0-dryrun.mobileconfig

# Discard the unsigned artifacts so they cannot ride into any later
# step or upload. The release path will rebuild from scratch.
rm -rf dist build/dry-run-ca build/macos

- name: Verify required Secrets present
if: env.IS_RELEASE == 'true'
env:
MACOS_INSTALLER_CERT_P12: ${{ secrets.MACOS_INSTALLER_CERT_P12 }}
MACOS_INSTALLER_CERT_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_PASSWORD }}
MACOS_APPLICATION_CERT_P12: ${{ secrets.MACOS_APPLICATION_CERT_P12 }}
MACOS_APPLICATION_CERT_PASSWORD: ${{ secrets.MACOS_APPLICATION_CERT_PASSWORD }}
MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
MACOS_NOTARY_APP_PASSWORD: ${{ secrets.MACOS_NOTARY_APP_PASSWORD }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
MACOS_RELEASE_CA_CERT: ${{ secrets.MACOS_RELEASE_CA_CERT }}
MACOS_RELEASE_CA_KEY: ${{ secrets.MACOS_RELEASE_CA_KEY }}
run: |
missing=0
Comment thread
iamclaude697 marked this conversation as resolved.
for v in MACOS_INSTALLER_CERT_P12 MACOS_INSTALLER_CERT_PASSWORD \
MACOS_APPLICATION_CERT_P12 MACOS_APPLICATION_CERT_PASSWORD \
MACOS_NOTARY_APPLE_ID MACOS_NOTARY_APP_PASSWORD \
MACOS_NOTARY_TEAM_ID MACOS_KEYCHAIN_PASSWORD \
MACOS_RELEASE_CA_CERT MACOS_RELEASE_CA_KEY; do
if [ -z "${!v:-}" ]; then
echo "::error::Required secret $v is missing"
missing=1
fi
done
[ "$missing" -eq 0 ]

- name: Set up signing keychain
if: env.IS_RELEASE == 'true'
env:
MACOS_INSTALLER_CERT_P12: ${{ secrets.MACOS_INSTALLER_CERT_P12 }}
MACOS_INSTALLER_CERT_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERT_PASSWORD }}
MACOS_APPLICATION_CERT_P12: ${{ secrets.MACOS_APPLICATION_CERT_P12 }}
MACOS_APPLICATION_CERT_PASSWORD: ${{ secrets.MACOS_APPLICATION_CERT_PASSWORD }}
MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
# P12 files briefly land in the workspace. Trap on EXIT cleans them
# up on every exit path (including `security import` failure),
# not just the happy path — H5 remediation.
trap 'rm -f installer.p12 app.p12' EXIT

KEYCHAIN=build.keychain
security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
security default-keychain -s "$KEYCHAIN"
security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -t 7200 -u "$KEYCHAIN"

echo "$MACOS_INSTALLER_CERT_P12" | base64 -d > installer.p12
security import installer.p12 -k "$KEYCHAIN" -P "$MACOS_INSTALLER_CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/security

echo "$MACOS_APPLICATION_CERT_P12" | base64 -d > app.p12
security import app.p12 -k "$KEYCHAIN" -P "$MACOS_APPLICATION_CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/security

security set-key-partition-list -S apple-tool:,apple: \
Comment thread
iamclaude697 marked this conversation as resolved.
-s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN"

INSTALLER=$(security find-identity -v -p basic "$KEYCHAIN" \
| grep "Developer ID Installer" | head -1 | awk -F'"' '{print $2}')
APP=$(security find-identity -v -p basic "$KEYCHAIN" \
| grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}')
if [ -z "$INSTALLER" ] || [ -z "$APP" ]; then
echo "::error::Could not resolve signing identities from keychain"
exit 1
fi
echo "MACOS_INSTALLER_IDENTITY=$INSTALLER" >> "$GITHUB_ENV"
echo "MACOS_APPLICATION_IDENTITY=$APP" >> "$GITHUB_ENV"

- name: Stage release CA
if: env.IS_RELEASE == 'true'
env:
MACOS_RELEASE_CA_CERT: ${{ secrets.MACOS_RELEASE_CA_CERT }}
MACOS_RELEASE_CA_KEY: ${{ secrets.MACOS_RELEASE_CA_KEY }}
run: |
mkdir -p build/release-ca
echo "$MACOS_RELEASE_CA_CERT" | base64 -d > build/release-ca/ca-cert.pem
echo "$MACOS_RELEASE_CA_KEY" | base64 -d > build/release-ca/ca-key.pem
chmod 0600 build/release-ca/ca-key.pem
echo "CA_CERT_FILE_FOR_RELEASE=$PWD/build/release-ca/ca-cert.pem" >> "$GITHUB_ENV"

- name: Build PKG
if: env.IS_RELEASE == 'true'
env:
PKG_VERSION: ${{ github.ref_name }}
Comment thread
iamclaude697 marked this conversation as resolved.
run: make package-macos-pkg

- name: Build .mobileconfig
if: env.IS_RELEASE == 'true'
env:
PKG_VERSION: ${{ github.ref_name }}
run: make package-macos-mobileconfig

# Release-gate reminder: before pushing a v* tag that reaches this step,
# the .mobileconfig HTTPS-interception verification ritual MUST have
# been executed on an MDM-enrolled / profile-enrolled macOS host.
# Procedure + pass criterion: see issue #125 and docs/packaging/macos.md
# § "Pre-tag verification ritual". CI cannot enforce this on its own —
# the Linux executor for PRs cannot exercise CFNetwork — so the
# ritual is a human gate. Do not tag without a recorded pass.
- name: Notarize PKG
if: env.IS_RELEASE == 'true'
env:
NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }}
NOTARY_APP_PASSWORD: ${{ secrets.MACOS_NOTARY_APP_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
run: |
xcrun notarytool store-credentials notary-profile \
--apple-id "$NOTARY_APPLE_ID" \
--password "$NOTARY_APP_PASSWORD" \
--team-id "$NOTARY_TEAM_ID"
for pkg in dist/*.pkg; do
PKG_PATH="$pkg" NOTARY_PROFILE=notary-profile bash packaging/macos/pkg/notarize.sh
done

- name: Verify artifacts
if: env.IS_RELEASE == 'true'
run: |
for pkg in dist/*.pkg; do
pkgutil --check-signature "$pkg"
spctl --assess --type install --verbose=2 "$pkg"
done
for mc in dist/*.mobileconfig; do
plutil -lint "$mc"
/usr/bin/security cms -D -i "$mc" >/dev/null
done

- name: Cleanup release CA
if: always()
run: rm -rf build/release-ca

# Explicit file list — H6: glob (dist/*) could pick up adjacent build
# artifacts if a future helper writes to dist/. Named files only.
- name: Upload artifacts
if: env.IS_RELEASE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: macos
Comment thread
iamclaude697 marked this conversation as resolved.
path: |
dist/ai-proxy-*-universal.pkg
dist/ai-proxy-*.mobileconfig
if-no-files-found: error

- name: Cleanup keychain
if: always()
run: security delete-keychain build.keychain || true

release:
name: Sign + publish
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@448e3f862ab3ef47aa50ff917776823c9946035b # v7.0.0
with:
name: macos
path: dist/

- name: Generate checksums
working-directory: dist
run: |
shasum -a 256 *.pkg *.mobileconfig > SHA256SUMS-macos
shasum -a 512 *.pkg *.mobileconfig > SHA512SUMS-macos

- name: Install cosign
uses: sigstore/cosign-installer@d7d6e113b16d7d56d4c136e46ea9d2f80ce5d4be # v3.7.0

- name: Sign release artifacts (keyless)
working-directory: dist
run: |
for f in *.pkg *.mobileconfig SHA256SUMS-macos SHA512SUMS-macos; do
cosign sign-blob --yes \
--output-signature "$f.sig" \
--output-certificate "$f.cert" \
"$f"
done

- name: Create GitHub release
uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # v2.0.9
with:
files: dist/*
fail_on_unmatched_files: true
22 changes: 21 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ BUILD_FLAGS := -ldflags="-s -w"
CA_CERT := ca-cert.pem
CA_KEY := ca-key.pem

.PHONY: all build run clean test lint security vulncheck check benchmark gen-ca import-ca-macos import-ca-linux import-ca-windows import-ca-macos-user import-ca-linux-user import-ca-windows-user deploy package-linux package-linux-amd64 package-linux-arm64
.PHONY: all build run clean test lint security vulncheck check benchmark gen-ca import-ca-macos import-ca-linux import-ca-windows import-ca-macos-user import-ca-linux-user import-ca-windows-user deploy package-linux package-linux-amd64 package-linux-arm64 package-macos package-macos-pkg package-macos-mobileconfig

all: build

Expand Down Expand Up @@ -152,3 +152,23 @@ package-linux-arm64:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 $(GO) build $(BUILD_FLAGS) -o bin/ai-proxy $(CMD)
ARCH=arm64 VERSION=$(PKG_VERSION) $(NFPM) package -f packaging/linux/nfpm.yaml -p deb -t dist/
ARCH=arm64 VERSION=$(PKG_VERSION) $(NFPM) package -f packaging/linux/nfpm.yaml -p rpm -t dist/

# --- UEM macOS packaging (Phase 2) ---
# Both targets are macOS-only. Running on Linux fails fast — pkgbuild,
# productbuild, codesign, security, and notarytool are macOS-host tools.

package-macos: package-macos-pkg package-macos-mobileconfig

package-macos-pkg:
@[ "$$(uname -s)" = "Darwin" ] || { echo "package-macos-pkg requires macOS"; exit 1; }
VERSION=$(PKG_VERSION) ARCH=universal \
INSTALLER_IDENTITY="$$MACOS_INSTALLER_IDENTITY" \
APPLICATION_IDENTITY="$$MACOS_APPLICATION_IDENTITY" \
bash packaging/macos/pkg/build.sh

package-macos-mobileconfig:
@[ "$$(uname -s)" = "Darwin" ] || { echo "package-macos-mobileconfig requires macOS"; exit 1; }
VERSION=$(PKG_VERSION) \
CA_CERT=$(CA_CERT_FILE_FOR_RELEASE) \
APPLICATION_IDENTITY="$$MACOS_APPLICATION_IDENTITY" \
bash packaging/macos/mobileconfig/build.sh
2 changes: 1 addition & 1 deletion docs/packaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Native OS packages for unattended UEM deployment (Intune, JAMF, SCCM, etc.).
| Platform | Format | Status | Doc |
|---|---|---|---|
| Linux | `.deb`, `.rpm` (amd64, arm64) | shipped | [`linux.md`](./linux.md) |
| macOS | `.pkg` + `.mobileconfig` | planned | _phase 2_ |
| macOS | `.pkg` + `.mobileconfig` | shipped | [`macos.md`](./macos.md) |
| Windows | MSI | planned | _phase 3_ |

Each phase covers silent install, service registration, OS trust-store integration, externalized configuration, and clean uninstall. Source under `packaging/<platform>/`.
Loading
Loading