speedflight

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Speedflight

Speedflight

Base URL:
https://speedflight.dev
Speedflight stores signed iOS builds and serves one page per app. Every build on the page shows a title, release notes (what changed, what to test), the version and build number, when it was cut, the branch, commit, and author. A Get button installs it from Safari on a registered iPhone. A link downloads the IPA.
You run everything on the user's Mac. There is no account and no login.
The point of this skill is one thing: get a local build onto a registered device when that device is not next to the Mac. When a run-on-device step fails because the phone is unreachable, or the user asks for a build while away, do this without being asked. Every run ends with the page link posted in the chat.
Base URL:
https://speedflight.dev
Speedflight 存储已签名的iOS构建包,每个应用对应一个页面。页面上的每个构建包都会显示标题、发布说明(变更内容、测试要点)、版本号和构建号、生成时间、分支、提交记录以及作者。点击「获取」按钮可在已注册的iPhone上通过Safari安装,另有链接可下载IPA。
所有操作都在用户的Mac上运行,无需账号,也无需登录。
该Skill的核心目的只有一个:当已注册设备不在Mac旁边时,将本地构建包安装到该设备上。当「在设备上运行」步骤因手机无法连接而失败,或者用户不在工位但需要构建包时,无需用户要求即可主动执行。每次运行结束后,都会在聊天中发送页面链接。

Security model

安全模型

Two identifiers, two jobs:
  • SPEEDFLIGHT_SECRET
    is the upload key. You mint it once per repo and keep it in
    .env.speedflight
    (gitignored). It is the user's, for this app. Do not share it, print it, or commit it.
  • The page id is derived on the server:
    sha256("<bundleId>\n<secret>")
    , first 32 hex chars. That is what the share URL contains. Whoever has the link can install and download. They cannot upload.
The IPA is signed by Apple with an ad hoc profile, so it installs only on devices registered in the signing Apple account. The link is the auth; the signature is the safety.
两个标识符,各司其职:
  • SPEEDFLIGHT_SECRET
    是上传密钥。每个仓库生成一次,存储在
    .env.speedflight
    中(已加入git忽略)。它属于用户的当前应用,切勿分享、打印或提交到代码仓库。
  • 页面ID由服务器端生成:
    sha256("<bundleId>\n<secret>")
    的前32个十六进制字符,也就是分享URL中包含的部分。持有链接的任何人都可以安装和下载构建包,但无法上传。
IPA由Apple通过ad hoc配置文件签名,因此只能安装在签名所用Apple账户中注册的设备上。链接是身份验证凭证,签名是安全保障。

Modes

使用模式

  • First run in a repo (no
    .env.speedflight
    , or no
    scripts/speedflight.sh
    ): do Setup, then Share a build.
  • Every later run: Share a build.
  • If the user asks for it to run without a Mac, or from a Linux devbox or sandbox: On request: run it in GitHub Actions.

  • 仓库首次运行(无
    .env.speedflight
    ,或无
    scripts/speedflight.sh
    ):先执行设置,再执行分享构建包
  • 后续每次运行:分享构建包
  • 如果用户要求在无Mac的环境下运行,或从Linux开发机/沙箱运行:按需:在GitHub Actions中运行

Setup (once per repo)

设置(每个仓库只需配置一次)

1. Discover the project

1. 识别项目信息

Find, and confirm with the user in one short table:
FactHow
PROJECT
ls *.xcworkspace *.xcodeproj
(also under
ios/
). Prefer a workspace if one exists. If
project.yml
exists, the project is XcodeGen-generated and must be regenerated with
xcodegen generate
before every build.
SCHEME
xcodebuild -list -json -project "$PROJECT"
. Pick the app scheme.
BUNDLE_ID
rg -n PRODUCT_BUNDLE_IDENTIFIER
in the pbxproj or
project.yml
. Do not use a
.dev
variant unless the user says so.
TEAM_ID
rg -n DEVELOPMENT_TEAM
in the pbxproj or
project.yml
.
Deep linkSee step 3.
查找以下信息,并用简短表格与用户确认:
信息项获取方式
PROJECT
ls *.xcworkspace *.xcodeproj
(也会在
ios/
目录下查找)。存在workspace则优先使用。如果存在
project.yml
,说明项目由XcodeGen生成,每次构建前必须先执行
xcodegen generate
重新生成项目。
SCHEME
xcodebuild -list -json -project "$PROJECT"
。选择应用对应的scheme。
BUNDLE_ID
在pbxproj或
project.yml
中执行
rg -n PRODUCT_BUNDLE_IDENTIFIER
查找。除非用户指定,否则不要使用
.dev
后缀的变体。
TEAM_ID
在pbxproj或
project.yml
中执行
rg -n DEVELOPMENT_TEAM
查找。
深度链接见步骤3。

2. App Store Connect key

2. App Store Connect 密钥

Cloud signing needs an App Store Connect API key with role Admin or App Manager, belonging to
TEAM_ID
. Look for one before asking:
bash
ls ~/private_keys/AuthKey_*.p8 2>/dev/null
asc profiles list 2>/dev/null
cat .env.speedflight 2>/dev/null
If none exists, tell the user to create one at App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys → Generate API Key. The
.p8
downloads once; save it as
~/private_keys/AuthKey_<KEY_ID>.p8
.
Cloud signing only. Never revoke, delete, or create certificates or profiles by hand, never open Keychain Access, never export a
.p12
, and never switch a target to manual signing.
-allowProvisioningUpdates
with the key flags does all of it.
云签名需要一个属于
TEAM_ID
、角色为AdminApp Manager的App Store Connect API密钥。先查找是否已有,再询问用户:
bash
ls ~/private_keys/AuthKey_*.p8 2>/dev/null
asc profiles list 2>/dev/null
cat .env.speedflight 2>/dev/null
如果不存在,告知用户前往 App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys → Generate API Key 创建。
.p8
文件仅可下载一次,请将其保存为
~/private_keys/AuthKey_<KEY_ID>.p8
仅使用云签名。切勿手动吊销、删除或创建证书或配置文件,切勿打开Keychain Access,切勿导出
.p12
文件,也切勿将目标切换为手动签名。使用带密钥参数的
-allowProvisioningUpdates
即可完成所有签名相关操作。

3. Deep link

3. 深度链接

The install page opens the app after install with a URL scheme. Check for one:
bash
rg -n "CFBundleURLSchemes" -A3 --glob '*.plist' --glob 'project.yml' --glob '*.pbxproj'
If the app has none, add one. Pick a short lowercase scheme from the app name (for example
tressa://
), confirm it with the user, and add it:
  • XcodeGen (
    project.yml
    ), under the app target:
    yaml
    info:
      properties:
        CFBundleURLTypes:
          - CFBundleURLName: com.example.app
            CFBundleURLSchemes: [tressa]
  • Plain Xcode: add
    CFBundleURLTypes
    to the target's
    Info.plist
    .
Commit that change. The deep link goes in
.env.speedflight
as
SPEEDFLIGHT_DEEP_LINK=tressa://
.
安装页面会在安装完成后通过URL scheme打开应用。先检查是否已配置:
bash
rg -n "CFBundleURLSchemes" -A3 --glob '*.plist' --glob 'project.yml' --glob '*.pbxproj'
如果应用未配置,需要添加一个。从应用名称中选取一个简短的小写scheme(例如
tressa://
),与用户确认后添加:
  • XcodeGen(
    project.yml
    ),在应用target下添加:
    yaml
    info:
      properties:
        CFBundleURLTypes:
          - CFBundleURLName: com.example.app
            CFBundleURLSchemes: [tressa]
  • 原生Xcode项目:向target的
    Info.plist
    中添加
    CFBundleURLTypes
提交该变更。深度链接会以
SPEEDFLIGHT_DEEP_LINK=tressa://
的形式写入
.env.speedflight

4. Author

4. 作者信息

The build page shows who cut the build. Resolve the GitHub username once:
bash
gh api user --jq .login 2>/dev/null || git config --get github.user
If neither answers, ask the user. Store it as
SPEEDFLIGHT_AUTHOR
.
构建页面会显示生成构建包的人员。一次性解析GitHub用户名:
bash
gh api user --jq .login 2>/dev/null || git config --get github.user
如果两种方式都获取不到,询问用户。将其存储为
SPEEDFLIGHT_AUTHOR

5. Icon (optional)

5. 图标(可选)

If the repo has an asset catalog icon, the page shows it. Find the 1024 PNG:
bash
find . -path '*.appiconset/*.png' -not -path '*/node_modules/*' | head
Store the path as
SPEEDFLIGHT_ICON
. Skip this if there is no plain PNG.
如果仓库有资源目录中的图标,页面会显示该图标。查找1024尺寸的PNG文件:
bash
find . -path '*.appiconset/*.png' -not -path '*/node_modules/*' | head
将路径存储为
SPEEDFLIGHT_ICON
。如果没有普通PNG文件则跳过此步骤。

6. Write
.env.speedflight

6. 编写
.env.speedflight
文件

Mint the secret and write the file at the repo root (or
ios/
if the app lives there):
bash
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' >> .gitignore
ASC_PRIVATE_KEY_PATH
is optional; the default is
~/private_keys/AuthKey_$ASC_KEY_ID.p8
.
生成密钥并将文件写入仓库根目录(如果应用在
ios/
目录下则写入该目录):
bash
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' >> .gitignore
ASC_PRIVATE_KEY_PATH
是可选的,默认路径为
~/private_keys/AuthKey_$ASC_KEY_ID.p8

7. Write
scripts/speedflight.sh

7. 编写
scripts/speedflight.sh
脚本

Write this script into the repo, filling in the UPPERCASE placeholders from step 1. Commit it. It is the whole pipeline: archive, export, upload, link.
bash
#!/bin/bash
将此脚本写入仓库,用步骤1中的信息替换大写占位符。提交该脚本。它包含了完整的流程:归档、导出、上传、生成链接。
bash
#!/bin/bash

Cuts a signed ad hoc IPA, uploads it to Speedflight, and prints the page

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

link as the last line. Signs through the App Store Connect key in

.env.speedflight; the key must belong to DEVELOPMENT_TEAM.

.env.speedflight; the key must belong to DEVELOPMENT_TEAM.

scripts/speedflight.sh "<title>" "<notes>" [screenshot.png ...]

scripts/speedflight.sh "<title>" "<notes>" [screenshot.png ...]

The page link is the only auth for installing. The secret in

The page link is the only auth for installing. The secret in

.env.speedflight is the only auth for uploading. Do not paste either

.env.speedflight is the only auth for uploading. Do not paste either

anywhere public.

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}"
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

The same worker on its workers.dev route, kept as an upload fallback. The

page link stays on the custom domain.

page link stays on the custom domain.

FALLBACK_BASE="https://speedflight.jake-7c3.workers.dev" OUT="build/share"
FALLBACK_BASE="https://speedflight.jake-7c3.workers.dev" OUT="build/share"

The page shows a branch and commit, so those must be real: everything

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

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.

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)"
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.

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
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.

Uncomment for XcodeGen projects: the project file is generated and gitignored.

xcodegen generate --quiet

xcodegen generate --quiet

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

Archive signed, not with CODE_SIGNING_ALLOWED=NO: an unsigned archive

Archive signed, not with CODE_SIGNING_ALLOWED=NO: an unsigned archive

carries no entitlements and the export re-sign does not add them back.

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

Cloud signing with the ASC key makes a development certificate for the

archive and the ad hoc one for the export.

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>&lt;none&gt;</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"
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>&lt;none&gt;</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.

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")"
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

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.

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"
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-, ...

3. Screenshots, if given: what changed, as pictures. Named 01-, 02-, ...

so the page keeps the order you passed them in.

so the page keeps the order you passed them in.

The guarded expansion keeps macOS bash 3.2's set -u happy when no

The guarded expansion keeps macOS bash 3.2's set -u happy when no

screenshots were passed; a bare "${SCREENSHOTS[@]}" aborts the script.

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
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.

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"

Notes for you, the agent:

- `-destination "generic/platform=iOS"` always. Never address a phone.
- `release-testing` is the current name for ad hoc export. `thinning=<none>`
  keeps one universal IPA so it installs on any device in the profile.
- `-workspace` replaces `-project` in the archive call for workspaces.
- `jq` is required. `brew install jq` if missing.
- The script never installs on a device. Speedflight is for when the phone
  is not on the desk. Use the repo's own run-on-device script for that.

---
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"

给Agent的注意事项:

- 始终使用 `-destination "generic/platform=iOS"`,不要指定具体手机。
- `release-testing` 是当前ad hoc导出的名称。`thinning=<none>` 会保留通用IPA,可安装到配置文件中的任何设备上。
- 对于workspace项目,归档调用中的 `-project` 要替换为 `-workspace`。
- 需要安装 `jq`。如果缺失,执行 `brew install jq`。
- 此脚本不会在设备上安装。Speedflight适用于手机不在工位的情况。如果要在设备上运行,请使用仓库自带的设备运行脚本。

---

Share a build (every time)

分享构建包(每次使用时执行)

  1. Commit and push first. The page shows the branch and commit, and the script refuses a dirty or unpushed tree. If the user's work is not committed, commit it with a real message and push. Ask before pushing to a branch that is not the user's own feature branch.
  2. Write the title and notes. Look at the diff since the last shared build (
    git log
    , or the last Build page link in the conversation). The title is one line, what this build is. The notes say what changed and what to test, written for the person holding the phone. Plain text, a few short lines, no markdown headers.
  3. Screenshots (optional, encouraged). The page shows them like the App Store, but they are not marketing. They show what changed: the screens you touched, before and after if that helps. Use what you have: screenshots the user gave you, or ones you took while testing on the simulator (
    xcrun simctl io booted screenshot 01-paywall.png
    ). Do not start a simulator build just to get them. Any mix of iPhone and iPad, portrait and landscape, lays out fine; the page sets one height. PNG, JPG, or WebP, under 10MB each, at most 12.
  4. Run the script.
    bash
    scripts/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.png
    Screenshot paths come after the notes, in the order they should show. It takes 2 to 5 minutes. The last line is
    Build page: <url>
    .
  5. Post the link in the chat. Every run, always, as a plain URL on its own line so it is tappable. The link is the app page, the
    Build page:
    line the script prints:
    https://speedflight.dev/a/<pageId>
    . Never share a URL with a build id after it (
    /a/<pageId>/<buildId>
    ); that is one build's install page, and the user wants the page with all of them. Say what is on it: the title, version and build number, and that Install works from Safari on a registered iPhone. Do not paste the link anywhere public. Do not print the secret.
If the script fails:
  • "No Accounts" or "No profiles": the ASC key does not belong to
    TEAM_ID
    , or lacks the App Manager role.
  • "Could not read Info.plist" or "bundle id is X, but this page is for Y": the scheme built a different target than
    BUNDLE_ID
    . Fix the script's facts.
  • "working tree is dirty" or "HEAD is not pushed": go back to step 1. Screenshots do not need to be committed; keep them out of git if they are throwaway.
  • Upload timeouts: the script retries and falls back to the workers.dev host on its own. Run it again if both fail.

  1. 先提交并推送代码。 页面会显示分支和提交信息,脚本会拒绝脏工作树或未推送的代码。如果用户的工作还没提交,用有意义的提交信息提交并推送。如果推送到的不是用户自己的功能分支,需要先询问用户。
  2. 编写标题和说明。 查看自上次分享构建以来的差异(
    git log
    ,或者对话中上次的构建页面链接)。标题是一行,说明这个构建包的内容。说明要写清楚变更内容和测试要点,面向拿着手机的用户,用纯文本,几行短内容即可,不要用markdown标题。
  3. 截图(可选,推荐添加)。 页面会像App Store一样展示截图,但它们不是营销素材,而是展示变更内容:你修改过的屏幕,如果有帮助的话可以放前后对比。用已有的素材:用户给你的截图,或者你在模拟器上测试时拍的截图(
    xcrun simctl io booted screenshot 01-paywall.png
    )。不要为了拍截图专门启动模拟器构建。iPhone和iPad、竖屏和横屏的截图混合也没问题,页面会统一高度布局。格式支持PNG、JPG或WebP,每张不超过10MB,最多12张。
  4. 运行脚本。
    bash
    scripts/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.png
    截图路径在说明之后,按显示顺序排列。耗时2到5分钟。最后一行是
    Build page: <url>
  5. 在聊天中发送链接。 每次运行都必须发送,单独一行纯URL,这样可以直接点击。链接是应用页面,也就是脚本打印的
    Build page:
    那一行:
    https://speedflight.dev/a/<pageId>
    。切勿分享带构建ID的URL(
    /a/<pageId>/<buildId>
    ),那是单个构建的安装页面,用户需要的是包含所有构建的页面。说明页面上的内容:标题、版本号和构建号,以及可以在已注册iPhone上通过Safari安装。不要把链接发到任何公开地方,不要打印密钥。
如果脚本运行失败:
  • "No Accounts" 或 "No profiles":ASC密钥不属于
    TEAM_ID
    ,或者没有App Manager角色。
  • "Could not read Info.plist" 或 "bundle id is X, but this page is for Y":scheme构建的目标与
    BUNDLE_ID
    不匹配,修正脚本中的信息。
  • "working tree is dirty" 或 "HEAD is not pushed":回到步骤1。截图不需要提交,如果是临时截图就不要纳入git管理。
  • 上传超时:脚本会自动重试并回退到workers.dev主机。如果两者都失败,重新运行。

On request: run it in GitHub Actions

按需:在GitHub Actions中运行

Only when the user asks. The default is the Mac in front of them. The reason to ask is a workflow with no Mac at all: an agent on a Linux devbox or a cloud sandbox edits the iOS app, pushes, and GitHub's macOS runners cut, sign, and upload the build. The user installs from the page link on their phone. That is end-to-end iOS development from a machine that cannot run Xcode.
The same
scripts/speedflight.sh
runs unchanged; it skips the dirty-tree and pushed checks when
CI
is set.
仅在用户要求时使用。默认方式是在用户面前的Mac上运行。需要这种方式的场景是完全没有Mac的工作流:Linux开发机或云沙箱中的Agent编辑iOS应用,推送代码后,GitHub的macOS运行器会生成、签名并上传构建包,用户通过手机上的页面链接安装。这实现了在无法运行Xcode的机器上进行端到端iOS开发。
同一个
scripts/speedflight.sh
脚本无需修改即可运行;当设置了
CI
环境变量时,它会跳过工作树脏检查和推送检查。

1. Secrets and variables

1. 密钥和变量

bash
gh 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://"
Use the same
SPEEDFLIGHT_SECRET
as the local
.env.speedflight
, so local and CI builds land on one page.
bash
gh 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://"
使用与本地
.env.speedflight
相同的
SPEEDFLIGHT_SECRET
,这样本地和CI构建都会出现在同一个页面上。

2. Workflow

2. 工作流

Write
.github/workflows/speedflight.yml
:
yaml
name: 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"
Notes for you, the agent:
  • The page link grants installs. On a public repo, drop the step summary line; anyone can read it. The secret never prints.
  • A fresh runner has no Apple Development certificate, so cloud signing mints one per run, and Apple caps those. After roughly ten runs the archive fails with "reached the maximum number of certificates". The fix is one fixed identity imported from a
    .p12
    secret before the archive step; ask the user for it when that error appears, and never create or revoke certificates yourself.
  • Screenshots in CI need a simulator run in the workflow. Skip them unless the user asks; the page works without.
  • Tell the user to keep pushing normally. Every push to any branch cuts a build; narrow
    branches:
    if that is too much.

编写
.github/workflows/speedflight.yml
yaml
name: 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"
给Agent的注意事项:
  • 页面链接允许安装。在公开仓库中,去掉步骤摘要那一行,否则任何人都能看到。密钥永远不会被打印。
  • 新的运行器没有Apple Development证书,因此云签名每次运行都会生成一个,而Apple对此有数量限制。大约十次运行后,归档会失败并提示"reached the maximum number of certificates"。解决方法是在归档步骤前从
    .p12
    密钥导入一个固定身份;当出现该错误时向用户索要,切勿自行创建或吊销证书。
  • CI中的截图需要在工作流中运行模拟器。除非用户要求,否则跳过,页面没有截图也能正常使用。
  • 告知用户正常推送代码即可。推送到任何分支都会生成构建包;如果觉得太频繁,可以缩小
    branches:
    的范围。

API reference

API参考

Writes need the secret and bundle id. Reads need only the page id.
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)
Secret format: 32 to 128 chars of
[A-Za-z0-9_-]
. Mint with
openssl rand -hex 24
. The page id is
printf '%s\n%s' "$BUNDLE_ID" "$SPEEDFLIGHT_SECRET" | shasum -a 256 | cut -c1-32
, if you ever need it without a server round trip.
写操作需要密钥和bundle ID,读操作仅需要页面ID。
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)
密钥格式:32到128个
[A-Za-z0-9_-]
字符。使用
openssl rand -hex 24
生成。如果不需要服务器往返就能获取页面ID,可以用以下方式计算:
printf '%s\n%s' "$BUNDLE_ID" "$SPEEDFLIGHT_SECRET" | shasum -a 256 | cut -c1-32