Skip to content

Release

Release #4

Workflow file for this run

# Cut a release, end to end, from one manual trigger.
#
# Two jobs in one run, deliberately not tag-triggered: pushes made with the
# workflow's own GITHUB_TOKEN never fire other workflows (GitHub's recursion
# guard), so a "bump job pushes a tag, tag triggers a build workflow" design
# silently does nothing. Chaining the build job onto the bump job with
# `needs:` sidesteps that entirely and keeps the whole release visible as a
# single run.
#
# workflow_dispatch (choose patch/minor/major)
# └─ cut (ubuntu): compute next version from the latest v* tag,
# scripts/set-version.sh, commit, tag, push to main
# └─ build (macos): checkout that tag, build agent + CLI + daemon,
# SHA256SUMS, create the GitHub release
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump (house rule: minor for routine releases)"
type: choice
options: [patch, minor, major]
default: minor
permissions:
contents: write
jobs:
cut:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.ver.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0 # need the tag history to compute the next version
- name: Compute next version
id: ver
run: |
latest="$(git tag --list 'v*' --sort=-v:refname | head -1)"
latest="${latest:-v0.0.0}"
IFS=. read -r major minor patch <<< "${latest#v}"
case "${{ inputs.bump }}" in
major) version="$((major + 1)).0.0" ;;
minor) version="${major}.$((minor + 1)).0" ;;
patch) version="${major}.${minor}.$((patch + 1))" ;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
echo "bumping ${latest} -> v${version}"
- name: Bump, commit, tag, push
run: |
scripts/set-version.sh "${{ steps.ver.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# The tree can already be at the target version — it happened on the
# very first release, and it happens on any re-dispatch after a cut
# that pushed the commit to main but failed before the tag landed
# (the two refs in the push below are not atomic). Committing nothing
# and tagging HEAD makes that rerun self-heal; don't "clean this up".
if ! git diff --quiet; then
git commit -am "Release ${{ steps.ver.outputs.tag }}"
fi
git tag "${{ steps.ver.outputs.tag }}"
git push origin main "${{ steps.ver.outputs.tag }}"
build:
needs: cut
runs-on: macos-26
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.cut.outputs.tag }}
- uses: oven-sh/setup-bun@v2
- name: Build phantom-agent
run: |
cd phantom-agent
swift build -c release
cp .build/release/phantom-agent ../dist-agent
../dist-agent --version
- name: Build phantom CLI
run: |
cd phantom-cli
bun install --frozen-lockfile
bun build src/main.ts --compile --minify --outfile ../dist-phantom
bun build src/main-admin.ts --compile --minify --outfile ../dist-phantom-admin
../dist-phantom --version
../dist-phantom-admin --version
- name: Import signing certificate
env:
CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
run: |
# Throwaway keychain, dies with the runner. The partition-list step
# is what lets codesign use the key without a UI prompt.
keychain="$RUNNER_TEMP/release.keychain-db"
keychain_pass="$(uuidgen)"
security create-keychain -p "$keychain_pass" "$keychain"
security set-keychain-settings -lut 3600 "$keychain"
security unlock-keychain -p "$keychain_pass" "$keychain"
echo "$CERT_P12" | base64 -d > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" \
-P "$CERT_PASSWORD" -T /usr/bin/codesign
rm "$RUNNER_TEMP/cert.p12"
security set-key-partition-list -S apple-tool:,apple: \
-k "$keychain_pass" "$keychain" > /dev/null
security list-keychains -d user -s "$keychain" login.keychain-db
- name: Build phantom daemon
run: |
# ARCHS=arm64: Release otherwise also builds x86_64, where the
# VZMac* API family (macOS guests) doesn't exist at all.
# CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO: plain `xcodebuild build`
# (as opposed to archive/export) injects get-task-allow otherwise,
# and notarization rejects it.
xcodebuild -project phantom.xcodeproj -scheme phantom \
-configuration Release ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
DEVELOPMENT_TEAM=5FP33J8924 \
OTHER_CODE_SIGN_FLAGS=--timestamp \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \
build
# The project pins its build location (products land in the repo's
# DerivedData/, and -derivedDataPath is ignored) — ask xcodebuild
# where the app actually went instead of hardcoding it.
app_dir="$(xcodebuild -project phantom.xcodeproj -scheme phantom \
-configuration Release -showBuildSettings 2>/dev/null \
| awk -F' = ' '/ TARGET_BUILD_DIR =/{print $2; exit}')"
echo "app_dir=$app_dir" >> "$GITHUB_ENV"
# Belt and braces: a debug entitlement in a release is a build bug.
if codesign -d --entitlements :- "${app_dir}/Phantom.app" 2>/dev/null \
| grep -q get-task-allow; then
echo "::error::release app carries get-task-allow" >&2
exit 1
fi
- name: Notarize and staple
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }}
run: |
key="$RUNNER_TEMP/AuthKey.p8"
printf '%s\n' "$NOTARY_KEY_P8" > "$key"
ditto -c -k --keepParent "${app_dir}/Phantom.app" notarize.zip
out="$(xcrun notarytool submit notarize.zip \
--key "$key" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" \
--wait --timeout 30m --output-format json)"
echo "$out"
status="$(echo "$out" | jq -r .status)"
sub_id="$(echo "$out" | jq -r .id)"
# notarytool's exit code is not a reliable verdict — gate on the
# status field, and surface Apple's log when it isn't Accepted.
if [ "$status" != "Accepted" ]; then
xcrun notarytool log "$sub_id" \
--key "$key" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" || true
echo "::error::notarization $status" >&2
exit 1
fi
xcrun stapler staple "${app_dir}/Phantom.app"
# Re-zip after stapling: the ticket must ship inside the asset so
# Gatekeeper can verify offline.
ditto -c -k --keepParent "${app_dir}/Phantom.app" dist-phantom-app.zip
# No checksum file: GitHub computes an immutable SHA-256 per asset at
# upload and serves it as `asset.digest` (REST/GraphQL/gh, since
# 2025-06) — a SHA256SUMS.txt in the same release would duplicate that
# from inside the same trust domain.
#
# agent-install.sh is the guest bootstrap: a fresh VM fetches it from
# `releases/latest/download/` and pipes it to root sh, so it embeds the
# agent's SHA-256 and downloads the binary from this release's
# *versioned* URL — `latest` resolving mid-publish must not mix a new
# script with an old binary.
- name: Assemble
run: |
mkdir release
cp dist-agent release/phantom-agent
cp dist-phantom release/phantom-cli
cp dist-phantom-admin release/phantom-cli-admin
cp dist-phantom-app.zip release/phantom-daemon.zip
phantom-agent/make-install-script.sh dist-agent \
"https://github.com/${{ github.repository }}/releases/download/${{ needs.cut.outputs.tag }}/phantom-agent" \
> release/agent-install.sh
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ needs.cut.outputs.tag }}" \
release/* \
--title "${{ needs.cut.outputs.tag }}" \
--generate-notes