speedflight
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSpeedflight
Speedflight
Base URL:
https://speedflight.devSpeedflight 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.devSpeedflight 存储已签名的iOS构建包,每个应用对应一个页面。页面上的每个构建包都会显示标题、发布说明(变更内容、测试要点)、版本号和构建号、生成时间、分支、提交记录以及作者。点击「获取」按钮可在已注册的iPhone上通过Safari安装,另有链接可下载IPA。
所有操作都在用户的Mac上运行,无需账号,也无需登录。
该Skill的核心目的只有一个:当已注册设备不在Mac旁边时,将本地构建包安装到该设备上。当「在设备上运行」步骤因手机无法连接而失败,或者用户不在工位但需要构建包时,无需用户要求即可主动执行。每次运行结束后,都会在聊天中发送页面链接。
Security model
安全模型
Two identifiers, two jobs:
- is the upload key. You mint it once per repo and keep it in
SPEEDFLIGHT_SECRET(gitignored). It is the user's, for this app. Do not share it, print it, or commit it..env.speedflight - The page id is derived on the server: , first 32 hex chars. That is what the share URL contains. Whoever has the link can install and download. They cannot upload.
sha256("<bundleId>\n<secret>")
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中(已加入git忽略)。它属于用户的当前应用,切勿分享、打印或提交到代码仓库。.env.speedflight - 页面ID由服务器端生成:的前32个十六进制字符,也就是分享URL中包含的部分。持有链接的任何人都可以安装和下载构建包,但无法上传。
sha256("<bundleId>\n<secret>")
IPA由Apple通过ad hoc配置文件签名,因此只能安装在签名所用Apple账户中注册的设备上。链接是身份验证凭证,签名是安全保障。
Modes
使用模式
- First run in a repo (no , or no
.env.speedflight): do Setup, then Share a build.scripts/speedflight.sh - 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:
| Fact | How |
|---|---|
| |
| |
| |
| |
| Deep link | See step 3. |
查找以下信息,并用简短表格与用户确认:
| 信息项 | 获取方式 |
|---|---|
| |
| |
| 在pbxproj或 |
| 在pbxproj或 |
| 深度链接 | 见步骤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 . Look for one before asking:
TEAM_IDbash
ls ~/private_keys/AuthKey_*.p8 2>/dev/null
asc profiles list 2>/dev/null
cat .env.speedflight 2>/dev/nullIf 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 downloads once; save it as .
.p8~/private_keys/AuthKey_<KEY_ID>.p8Cloud signing only. Never revoke, delete, or create certificates or profiles
by hand, never open Keychain Access, never export a , and never switch
a target to manual signing. with the key flags
does all of it.
.p12-allowProvisioningUpdates云签名需要一个属于、角色为Admin或App Manager的App Store Connect API密钥。先查找是否已有,再询问用户:
TEAM_IDbash
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-allowProvisioningUpdates3. 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 ), confirm it with the user, and add it:
tressa://- XcodeGen (), under the app target:
project.ymlyamlinfo: properties: CFBundleURLTypes: - CFBundleURLName: com.example.app CFBundleURLSchemes: [tressa] - Plain Xcode: add to the target's
CFBundleURLTypes.Info.plist
Commit that change. The deep link goes in as
.
.env.speedflightSPEEDFLIGHT_DEEP_LINK=tressa://安装页面会在安装完成后通过URL scheme打开应用。先检查是否已配置:
bash
rg -n "CFBundleURLSchemes" -A3 --glob '*.plist' --glob 'project.yml' --glob '*.pbxproj'如果应用未配置,需要添加一个。从应用名称中选取一个简短的小写scheme(例如),与用户确认后添加:
tressa://- XcodeGen(),在应用target下添加:
project.ymlyamlinfo: properties: CFBundleURLTypes: - CFBundleURLName: com.example.app CFBundleURLSchemes: [tressa] - 原生Xcode项目:向target的中添加
Info.plist。CFBundleURLTypes
提交该变更。深度链接会以的形式写入。
SPEEDFLIGHT_DEEP_LINK=tressa://.env.speedflight4. 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.userIf 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_AUTHOR5. 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/*' | headStore the path as . Skip this if there is no plain PNG.
SPEEDFLIGHT_ICON如果仓库有资源目录中的图标,页面会显示该图标。查找1024尺寸的PNG文件:
bash
find . -path '*.appiconset/*.png' -not -path '*/node_modules/*' | head将路径存储为。如果没有普通PNG文件则跳过此步骤。
SPEEDFLIGHT_ICON6. Write .env.speedflight
.env.speedflight6. 编写.env.speedflight
文件
.env.speedflightMint the secret and write the file at the repo root (or if the app
lives there):
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' >> .gitignoreASC_PRIVATE_KEY_PATH~/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' >> .gitignoreASC_PRIVATE_KEY_PATH~/private_keys/AuthKey_$ASC_KEY_ID.p87. Write scripts/speedflight.sh
scripts/speedflight.sh7. 编写scripts/speedflight.sh
脚本
scripts/speedflight.shWrite 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/bashCuts 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
-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"
-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
-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"
-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}
--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}
--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"
-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"
-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
-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
-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
-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
-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)
分享构建包(每次使用时执行)
- 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.
- Write the title and notes. Look at the diff since the last shared
build (, 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.
git log - 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 (). 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.
xcrun simctl io booted screenshot 01-paywall.png - Run the script.
Screenshot paths come after the notes, in the order they should show. It takes 2 to 5 minutes. The last line isbash
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.Build page: <url> - 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 line the script prints:
Build page:. Never share a URL with a build id after it (https://speedflight.dev/a/<pageId>); 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./a/<pageId>/<buildId>
If the script fails:
- "No Accounts" or "No profiles": the ASC key does not belong to , or lacks the App Manager role.
TEAM_ID - "Could not read Info.plist" or "bundle id is X, but this page is for Y":
the scheme built a different target than . Fix the script's facts.
BUNDLE_ID - "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.
- 先提交并推送代码。 页面会显示分支和提交信息,脚本会拒绝脏工作树或未推送的代码。如果用户的工作还没提交,用有意义的提交信息提交并推送。如果推送到的不是用户自己的功能分支,需要先询问用户。
- 编写标题和说明。 查看自上次分享构建以来的差异(,或者对话中上次的构建页面链接)。标题是一行,说明这个构建包的内容。说明要写清楚变更内容和测试要点,面向拿着手机的用户,用纯文本,几行短内容即可,不要用markdown标题。
git log - 截图(可选,推荐添加)。 页面会像App Store一样展示截图,但它们不是营销素材,而是展示变更内容:你修改过的屏幕,如果有帮助的话可以放前后对比。用已有的素材:用户给你的截图,或者你在模拟器上测试时拍的截图()。不要为了拍截图专门启动模拟器构建。iPhone和iPad、竖屏和横屏的截图混合也没问题,页面会统一高度布局。格式支持PNG、JPG或WebP,每张不超过10MB,最多12张。
xcrun simctl io booted screenshot 01-paywall.png - 运行脚本。
截图路径在说明之后,按显示顺序排列。耗时2到5分钟。最后一行是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。Build page: <url> - 在聊天中发送链接。 每次运行都必须发送,单独一行纯URL,这样可以直接点击。链接是应用页面,也就是脚本打印的那一行:
Build page:。切勿分享带构建ID的URL(https://speedflight.dev/a/<pageId>),那是单个构建的安装页面,用户需要的是包含所有构建的页面。说明页面上的内容:标题、版本号和构建号,以及可以在已注册iPhone上通过Safari安装。不要把链接发到任何公开地方,不要打印密钥。/a/<pageId>/<buildId>
如果脚本运行失败:
- "No Accounts" 或 "No profiles":ASC密钥不属于,或者没有App Manager角色。
TEAM_ID - "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 runs unchanged; it skips the dirty-tree
and pushed checks when is set.
scripts/speedflight.shCI仅在用户要求时使用。默认方式是在用户面前的Mac上运行。需要这种方式的场景是完全没有Mac的工作流:Linux开发机或云沙箱中的Agent编辑iOS应用,推送代码后,GitHub的macOS运行器会生成、签名并上传构建包,用户通过手机上的页面链接安装。这实现了在无法运行Xcode的机器上进行端到端iOS开发。
同一个脚本无需修改即可运行;当设置了环境变量时,它会跳过工作树脏检查和推送检查。
scripts/speedflight.shCI1. 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 as the local , so local
and CI builds land on one page.
SPEEDFLIGHT_SECRET.env.speedflightbash
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://"使用与本地相同的,这样本地和CI构建都会出现在同一个页面上。
.env.speedflightSPEEDFLIGHT_SECRET2. Workflow
2. 工作流
Write :
.github/workflows/speedflight.ymlyaml
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 secret before the archive step; ask the user for it when that error appears, and never create or revoke certificates yourself.
.p12 - 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 if that is too much.
branches:
编写:
.github/workflows/speedflight.ymlyaml
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 . Mint with
. The page id is
,
if you ever need it without a server round trip.
[A-Za-z0-9_-]openssl rand -hex 24printf '%s\n%s' "$BUNDLE_ID" "$SPEEDFLIGHT_SECRET" | shasum -a 256 | cut -c1-32写操作需要密钥和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个字符。使用生成。如果不需要服务器往返就能获取页面ID,可以用以下方式计算:。
[A-Za-z0-9_-]openssl rand -hex 24printf '%s\n%s' "$BUNDLE_ID" "$SPEEDFLIGHT_SECRET" | shasum -a 256 | cut -c1-32