Loading...
Loading...
Share a local iOS build with a registered iPhone that is not connected to this Mac. Cuts a cloud-signed build, uploads it to Speedflight (speedflight.dev), and posts a link in the chat that installs from Safari. Use it proactively, without being asked, whenever the user wants a build on their device and the device is not plugged in or reachable (run-on-device fails, "send me the build", "I'm away from my desk", "put this on my phone"). Also use on "/speedflight" or "share a build", and to set up Speedflight for a repo.
npx skill4agent add jakemor/speedflight speedflighthttps://speedflight.devSPEEDFLIGHT_SECRET.env.speedflightsha256("<bundleId>\n<secret>").env.speedflightscripts/speedflight.sh| Fact | How |
|---|---|
| |
| |
| |
| |
| Deep link | See step 3. |
TEAM_IDls ~/private_keys/AuthKey_*.p8 2>/dev/null
asc profiles list 2>/dev/null
cat .env.speedflight 2>/dev/null.p8~/private_keys/AuthKey_<KEY_ID>.p8.p12-allowProvisioningUpdatesrg -n "CFBundleURLSchemes" -A3 --glob '*.plist' --glob 'project.yml' --glob '*.pbxproj'tressa://project.ymlinfo:
properties:
CFBundleURLTypes:
- CFBundleURLName: com.example.app
CFBundleURLSchemes: [tressa]CFBundleURLTypesInfo.plist.env.speedflightSPEEDFLIGHT_DEEP_LINK=tressa://gh api user --jq .login 2>/dev/null || git config --get github.userSPEEDFLIGHT_AUTHORfind . -path '*.appiconset/*.png' -not -path '*/node_modules/*' | headSPEEDFLIGHT_ICON.env.speedflightios/cat > .env.speedflight <<EOF
ASC_KEY_ID=ABC123DEFG
ASC_ISSUER_ID=12345678-abcd-....
SPEEDFLIGHT_SECRET=$(openssl rand -hex 24)
SPEEDFLIGHT_DEEP_LINK=tressa://
SPEEDFLIGHT_AUTHOR=jakemor
SPEEDFLIGHT_ICON=App/Assets.xcassets/AppIcon.appiconset/icon-1024.png
EOF
chmod 600 .env.speedflight
grep -qxF '.env.speedflight' .gitignore || echo '.env.speedflight' >> .gitignoreASC_PRIVATE_KEY_PATH~/private_keys/AuthKey_$ASC_KEY_ID.p8scripts/speedflight.sh#!/bin/bash
# Cuts a signed ad hoc IPA, uploads it to Speedflight, and prints the page
# link as the last line. Signs through the App Store Connect key in
# .env.speedflight; the key must belong to DEVELOPMENT_TEAM.
#
# scripts/speedflight.sh "<title>" "<notes>" [screenshot.png ...]
#
# The page link is the only auth for installing. The secret in
# .env.speedflight is the only auth for uploading. Do not paste either
# anywhere public.
set -euo pipefail
cd "$(dirname "$0")/.."
TITLE="${1:?usage: speedflight.sh \"<title>\" \"<notes>\" [screenshot.png ...]}"
NOTES="${2:?usage: speedflight.sh \"<title>\" \"<notes>\" [screenshot.png ...]}"
shift 2
SCREENSHOTS=("$@")
if [[ -f .env.speedflight ]]; then
set -a
# shellcheck disable=SC1091
source .env.speedflight
set +a
fi
: "${ASC_KEY_ID:?set ASC_KEY_ID in .env.speedflight}"
: "${ASC_ISSUER_ID:?set ASC_ISSUER_ID in .env.speedflight}"
: "${SPEEDFLIGHT_SECRET:?set SPEEDFLIGHT_SECRET in .env.speedflight}"
: "${SPEEDFLIGHT_DEEP_LINK:?set SPEEDFLIGHT_DEEP_LINK in .env.speedflight}"
: "${SPEEDFLIGHT_AUTHOR:?set SPEEDFLIGHT_AUTHOR in .env.speedflight}"
ASC_PRIVATE_KEY_PATH="${ASC_PRIVATE_KEY_PATH:-$HOME/private_keys/AuthKey_$ASC_KEY_ID.p8}"
[[ -f "$ASC_PRIVATE_KEY_PATH" ]] || { echo "missing ASC key file: $ASC_PRIVATE_KEY_PATH" >&2; exit 1; }
PROJECT="YOUR_APP.xcodeproj" # or YOUR_APP.xcworkspace, with -workspace below
SCHEME="YOUR_SCHEME"
BUNDLE_ID="com.example.yourapp"
TEAM_ID="YOUR_TEAM_ID"
BASE="${SPEEDFLIGHT_BASE:-https://speedflight.dev}"
# The same worker on its workers.dev route, kept as an upload fallback. The
# page link stays on the custom domain.
FALLBACK_BASE="https://speedflight.jake-7c3.workers.dev"
OUT="build/share"
# The page shows a branch and commit, so those must be real: everything
# committed, and the commit on the remote. Under CI the checkout is the
# pushed commit by definition, and a detached HEAD has no upstream to test.
BRANCH="${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}"
COMMIT="$(git rev-parse HEAD)"
# https form of origin, so the page can link the branch and commit.
REPO_URL="$(git remote get-url origin 2>/dev/null | sed -E 's#^git@([^:]+):#https://\1/#; s#\.git$##')"
case "$REPO_URL" in https://*) ;; *) REPO_URL="" ;; esac
if [[ -z "${CI:-}" ]]; then
if [[ -n "$(git status --porcelain)" ]]; then
echo "working tree is dirty: commit before sharing a build" >&2
exit 1
fi
if ! git merge-base --is-ancestor "$COMMIT" "@{u}" 2>/dev/null; then
echo "HEAD is not pushed: git push -u origin $BRANCH" >&2
exit 1
fi
fi
# Uncomment for XcodeGen projects: the project file is generated and gitignored.
# xcodegen generate --quiet
rm -rf "$OUT"
mkdir -p "$OUT"
# Archive signed, not with CODE_SIGNING_ALLOWED=NO: an unsigned archive
# carries no entitlements and the export re-sign does not add them back.
# Cloud signing with the ASC key makes a development certificate for the
# archive and the ad hoc one for the export.
xcodebuild -project "$PROJECT" -scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$OUT/App.xcarchive" \
-allowProvisioningUpdates \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
-authenticationKeyPath "$ASC_PRIVATE_KEY_PATH" \
-quiet archive
cat > "$OUT/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key><string>export</string>
<key>method</key><string>release-testing</string>
<key>signingStyle</key><string>automatic</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>thinning</key><string><none></string>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$OUT/App.xcarchive" \
-exportOptionsPlist "$OUT/ExportOptions.plist" \
-exportPath "$OUT/export" \
-allowProvisioningUpdates \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
-authenticationKeyPath "$ASC_PRIVATE_KEY_PATH" \
-quiet
mv "$OUT"/export/*.ipa "$OUT/signed.ipa"
# 1. Register the build with its metadata. The server answers with the ids.
META="$(jq -n \
--arg title "$TITLE" --arg notes "$NOTES" \
--arg deepLink "$SPEEDFLIGHT_DEEP_LINK" \
--arg branch "$BRANCH" --arg commit "$COMMIT" \
--arg author "$SPEEDFLIGHT_AUTHOR" \
--arg repoUrl "$REPO_URL" \
'{title:$title, notes:$notes, deepLink:$deepLink, branch:$branch, commit:$commit, author:$author}
+ (if $repoUrl == "" then {} else {repoUrl:$repoUrl} end)')"
create() {
curl -sfS --retry 3 --retry-all-errors --retry-delay 3 \
-X POST "$1/api/apps/$SPEEDFLIGHT_SECRET/$BUNDLE_ID/builds" \
-H "Content-Type: application/json" --data "$META"
}
CREATED="$(create "$BASE" || create "$FALLBACK_BASE")"
BUILD_ID="$(jq -r .buildId <<<"$CREATED")"
PAGE_URL="$(jq -r .pageUrl <<<"$CREATED")"
# 2. Upload the IPA. The server reads name, version, and build number from
# its Info.plist and rejects it if the bundle id does not match.
upload() {
curl -sfS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 \
-X PUT "$1/api/apps/$SPEEDFLIGHT_SECRET/$BUNDLE_ID/builds/$BUILD_ID/app.ipa" \
--data-binary @"$OUT/signed.ipa" >/dev/null
}
upload "$BASE" || upload "$FALLBACK_BASE"
# 3. Screenshots, if given: what changed, as pictures. Named 01-, 02-, ...
# so the page keeps the order you passed them in.
# The guarded expansion keeps macOS bash 3.2's set -u happy when no
# screenshots were passed; a bare "${SCREENSHOTS[@]}" aborts the script.
n=0
for shot in ${SCREENSHOTS[@]+"${SCREENSHOTS[@]}"}; do
[[ -f "$shot" ]] || { echo "no such screenshot: $shot" >&2; continue; }
n=$((n + 1))
ext="${shot##*.}"
case "$ext" in png|PNG) type=image/png ;; jpg|jpeg|JPG|JPEG) type=image/jpeg ;; webp) type=image/webp ;; *) echo "skip $shot: not png/jpg/webp" >&2; continue ;; esac
name="$(printf '%02d-%s' "$n" "$(basename "$shot" | tr -c 'A-Za-z0-9._-\n' '-')")"
curl -sfS --retry 3 --retry-all-errors --retry-delay 3 \
-X PUT "$BASE/api/apps/$SPEEDFLIGHT_SECRET/$BUNDLE_ID/builds/$BUILD_ID/screenshots/$name" \
-H "Content-Type: $type" --data-binary @"$shot" >/dev/null || echo "screenshot upload failed: $shot" >&2
done
# 4. Icon, if configured. Best effort.
if [[ -n "${SPEEDFLIGHT_ICON:-}" && -f "$SPEEDFLIGHT_ICON" ]]; then
curl -sS -X PUT "$BASE/api/apps/$SPEEDFLIGHT_SECRET/$BUNDLE_ID/icon" \
-H "Content-Type: image/png" --data-binary @"$SPEEDFLIGHT_ICON" >/dev/null || true
fi
echo
echo "Build page: $PAGE_URL"-destination "generic/platform=iOS"release-testingthinning=<none>-workspace-projectjqbrew install jqgit logxcrun simctl io booted screenshot 01-paywall.pngscripts/speedflight.sh "Onboarding paywall rewrite" "$(cat <<'EOF'
What changed
- New onboarding paywall with the annual plan first
- Fixed the crash when restoring purchases offline
What to test
- Fresh install, go through onboarding, tap Restore with Wi-Fi off
- Check the paywall shows 3 plans and the annual one is selected
EOF
)" shots/paywall.png shots/restore.pngBuild page: <url>Build page:https://speedflight.dev/a/<pageId>/a/<pageId>/<buildId>TEAM_IDBUNDLE_IDscripts/speedflight.shCIgh secret set ASC_KEY_ID --body "ABC123DEFG"
gh secret set ASC_ISSUER_ID --body "12345678-abcd-...."
gh secret set ASC_PRIVATE_KEY < ~/private_keys/AuthKey_ABC123DEFG.p8
gh secret set SPEEDFLIGHT_SECRET --body "$(grep SPEEDFLIGHT_SECRET .env.speedflight | cut -d= -f2)"
gh variable set SPEEDFLIGHT_DEEP_LINK --body "tressa://"SPEEDFLIGHT_SECRET.env.speedflight.github/workflows/speedflight.ymlname: Speedflight
on:
workflow_dispatch:
inputs:
title:
description: One line, what this build is
required: true
notes:
description: What changed and what to test
required: true
push:
branches: ["**"]
concurrency:
group: speedflight-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-latest
timeout-minutes: 45
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
SPEEDFLIGHT_SECRET: ${{ secrets.SPEEDFLIGHT_SECRET }}
SPEEDFLIGHT_DEEP_LINK: ${{ vars.SPEEDFLIGHT_DEEP_LINK }}
SPEEDFLIGHT_AUTHOR: ${{ github.actor }}
steps:
- uses: actions/checkout@v4
- name: Install the ASC key
run: |
mkdir -p ~/private_keys
printf '%s' "${{ secrets.ASC_PRIVATE_KEY }}" > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
# Only for XcodeGen projects.
- run: brew install xcodegen
- name: Cut and share
run: |
TITLE="${{ inputs.title }}"
NOTES="${{ inputs.notes }}"
# On push, the commit message is the title and its body the notes.
[ -n "$TITLE" ] || TITLE="$(git log -1 --format=%s)"
[ -n "$NOTES" ] || NOTES="$(git log -1 --format=%b)"
[ -n "$NOTES" ] || NOTES="$TITLE"
scripts/speedflight.sh "$TITLE" "$NOTES" | tee build.log
grep '^Build page:' build.log >> "$GITHUB_STEP_SUMMARY".p12branches:POST /api/apps/:secret/:bundleId/builds
JSON: {title, notes, deepLink, branch, commit, author} all required
{repoUrl} optional https repo URL; links branch + author on the page
-> 201 {buildId, pageId, pageUrl}
PUT /api/apps/:secret/:bundleId/builds/:buildId/app.ipa raw IPA bytes
-> {ok, appName, shortVersion, buildVersion, size, pageUrl}
PUT /api/apps/:secret/:bundleId/builds/:buildId/screenshots/:name raw image, under 10MB
name like 01-home.png (png, jpg, webp); at most 12 per build
PUT /api/apps/:secret/:bundleId/icon raw PNG, under 2MB
DELETE /api/apps/:secret/:bundleId/builds/:buildId
GET /api/pages/:pageId app + builds JSON
GET /api/pages/:pageId/builds/:buildId one build
GET /api/pages/:pageId/builds/:buildId/app.ipa download
GET /api/pages/:pageId/builds/:buildId/manifest.plist OTA manifest
Page to share: https://speedflight.dev/a/:pageId (this one, always)
One build's page: https://speedflight.dev/a/:pageId/:buildId (what the QR opens; do not share)[A-Za-z0-9_-]openssl rand -hex 24printf '%s\n%s' "$BUNDLE_ID" "$SPEEDFLIGHT_SECRET" | shasum -a 256 | cut -c1-32