777 lines
22 KiB
Markdown
777 lines
22 KiB
Markdown
---
|
||
name: dev-deploy
|
||
description: 应用部署技能。支持 iOS TestFlight、Docker 容器等多平台部署。当用户提到部署、发布、TestFlight、上架、build、archive 相关任务时自动激活。
|
||
---
|
||
|
||
# 应用部署 Skill (dev-deploy)
|
||
|
||
## 概述
|
||
|
||
管理应用从构建到发布的完整部署流程,支持多平台:
|
||
- **iOS**: TestFlight 内测 / App Store 发布
|
||
- **Docker**: Staging / Production 容器部署
|
||
|
||
集成 ai-proj 任务系统进行部署记录和需求阶段推进。
|
||
|
||
---
|
||
|
||
## 命令参考
|
||
|
||
| 命令 | 说明 |
|
||
|------|------|
|
||
| `/deploy ios` | iOS TestFlight 部署 |
|
||
| `/deploy docker [staging\|prod]` | Docker 容器部署 |
|
||
| `/deploy status` | 查看部署状态 |
|
||
|
||
---
|
||
|
||
## iOS TestFlight 部署
|
||
|
||
### 前置条件
|
||
|
||
| 项目 | 要求 |
|
||
|------|------|
|
||
| 构建机器 | macOS + Xcode(通过 SSH 访问) |
|
||
| 签名证书 | Apple Distribution 证书已安装在 Keychain |
|
||
| Provisioning Profile | App Store Distribution profile 已安装 |
|
||
| API Key | App Store Connect API Key (.p8) |
|
||
| sshpass | 本机安装用于非交互 SSH(`brew install hudochenkov/sshpass/sshpass`) |
|
||
| xcodegen | 构建机器安装用于从 project.yml 生成 xcodeproj |
|
||
|
||
### 完整部署流程
|
||
|
||
```
|
||
1. git push → 代码推送到远程仓库
|
||
2. SSH 连接构建机 → git pull 拉取最新代码
|
||
3. xcodebuild archive → 无签名构建 Archive
|
||
4. xcodebuild -exportArchive → Distribution 签名 + 上传 TestFlight
|
||
5. ASC API 补全 → 合规信息 + 测试说明 + build 关联版本
|
||
6. 验证 → 确认 TestFlight 状态为 IN_BETA_TESTING
|
||
```
|
||
|
||
### Step 1: SSH 连接构建机
|
||
|
||
```bash
|
||
# 使用 sshpass 进行非交互 SSH
|
||
sshpass -p '<password>' ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no <user>@<host> '<command>'
|
||
```
|
||
|
||
**关键经验**:
|
||
- SSH 远程 codesign 需要先**解锁 Keychain**,否则报 `errSecInternalComponent`
|
||
- 还需要 `set-key-partition-list` 授权 codesign 访问密钥
|
||
|
||
```bash
|
||
# 必须在每次 SSH 会话开头执行
|
||
security unlock-keychain -p "<password>" ~/Library/Keychains/login.keychain-db
|
||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "<password>" ~/Library/Keychains/login.keychain-db
|
||
```
|
||
|
||
### Step 2: 拉取代码
|
||
|
||
```bash
|
||
cd <repo-path> && git pull origin develop
|
||
```
|
||
|
||
### Step 3: Archive(无签名)
|
||
|
||
**关键经验**:Archive 阶段**不要签名**。原因:
|
||
- xcodebuild CLI 签名参数会泄漏到 SPM 依赖的 targets,导致 "does not support provisioning profiles" 错误
|
||
- 正确做法是 archive 时禁用签名,在 export 阶段单独签名
|
||
|
||
```bash
|
||
xcodebuild archive \
|
||
-project XiaoquCRM.xcodeproj \
|
||
-scheme XiaoquCRM \
|
||
-destination 'generic/platform=iOS' \
|
||
-configuration Release \
|
||
-archivePath ~/Desktop/XiaoquCRM.xcarchive \
|
||
-skipMacroValidation \
|
||
CODE_SIGNING_ALLOWED=NO \
|
||
CODE_SIGNING_REQUIRED=NO
|
||
```
|
||
|
||
**常见错误及解决**:
|
||
|
||
| 错误 | 原因 | 解决 |
|
||
|------|------|------|
|
||
| `Macro "X" must be enabled` | Swift Macros 安全限制 | 加 `-skipMacroValidation` |
|
||
| `cannot find type 'AdminFeature'` | xcodeproj 未包含新文件 | 运行 `xcodegen generate` 重新生成 |
|
||
| SPM 依赖报签名错误 | 签名参数泄漏到依赖 | Archive 用 `CODE_SIGNING_ALLOWED=NO` |
|
||
|
||
### Step 4: Export + 上传 TestFlight
|
||
|
||
```bash
|
||
# ExportOptions.plist(提前创建在构建机上)
|
||
cat > /tmp/ExportOptions.plist << EOF
|
||
<?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>method</key>
|
||
<string>app-store-connect</string>
|
||
<key>destination</key>
|
||
<string>upload</string>
|
||
<key>teamID</key>
|
||
<string>{TEAM_ID}</string>
|
||
<key>signingStyle</key>
|
||
<string>manual</string>
|
||
<key>signingCertificate</key>
|
||
<string>Apple Distribution</string>
|
||
<key>provisioningProfiles</key>
|
||
<dict>
|
||
<key>{BUNDLE_ID}</key>
|
||
<string>{PROFILE_NAME}</string>
|
||
</dict>
|
||
<key>manageAppVersionAndBuildNumber</key>
|
||
<true/>
|
||
</dict>
|
||
</plist>
|
||
EOF
|
||
|
||
# Export + Upload
|
||
xcodebuild -exportArchive \
|
||
-archivePath ~/Desktop/XiaoquCRM.xcarchive \
|
||
-exportOptionsPlist /tmp/ExportOptions.plist \
|
||
-exportPath ~/Desktop/XiaoquCRM-export \
|
||
-authenticationKeyPath {API_KEY_PATH} \
|
||
-authenticationKeyID {KEY_ID} \
|
||
-authenticationKeyIssuerID {ISSUER_ID}
|
||
```
|
||
|
||
**关键经验**:
|
||
|
||
| 问题 | 教训 |
|
||
|------|------|
|
||
| `errSecInternalComponent` | SSH 远程签名前必须 `unlock-keychain` + `set-key-partition-list` |
|
||
| `No signing certificate "iOS Distribution"` | 机器上没装 Distribution 证书,需在 Xcode > Accounts 登录 Apple ID 下载 |
|
||
| `Redundant Binary Upload` | build number 重复,需要在 project.yml 递增 `CURRENT_PROJECT_VERSION` |
|
||
| `Missing required icon file` | 需要 Assets.xcassets/AppIcon.appiconset 含 1024x1024 PNG |
|
||
| `UIInterfaceOrientation` iPad 错误 | 必须声明 iPad 四方向支持,或设置 `UIRequiresFullScreen=true` |
|
||
| `Cloud signing permission error` | API Key 权限不够或 Issuer ID 错误;改用手动签名 + 本地 profile |
|
||
|
||
### Step 5: ASC API 补全 TestFlight 信息
|
||
|
||
上传成功后,需要通过 App Store Connect API 补全三项信息,否则测试者收不到通知或无法安装:
|
||
|
||
#### 5.1 生成 JWT Token
|
||
|
||
```python
|
||
import jwt, time
|
||
key = open("AuthKey_XXXXXX.p8").read()
|
||
token = jwt.encode(
|
||
{"iss": "{ISSUER_ID}", "iat": int(time.time()),
|
||
"exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1"},
|
||
key, algorithm="ES256",
|
||
headers={"kid": "{KEY_ID}"} # ← 必须包含 kid!
|
||
)
|
||
```
|
||
|
||
**关键经验**:JWT 必须包含 `headers={"kid": KEY_ID}`,否则 401 认证失败。还需要安装 `cryptography` 库支持 ES256。
|
||
|
||
#### 5.2 设置出口合规
|
||
|
||
```
|
||
PATCH /v1/builds/{build_id}
|
||
{"data": {"type": "builds", "id": "{build_id}",
|
||
"attributes": {"usesNonExemptEncryption": false}}}
|
||
```
|
||
|
||
不设置此项,build 会卡在 "Missing Compliance" 状态,内部测试者无法安装。
|
||
|
||
#### 5.3 填写测试说明 (whatsNew)
|
||
|
||
```
|
||
# 先获取 localization ID
|
||
GET /v1/builds/{build_id}/betaBuildLocalizations
|
||
|
||
# 更新 whatsNew
|
||
PATCH /v1/betaBuildLocalizations/{loc_id}
|
||
{"data": {"type": "betaBuildLocalizations", "id": "{loc_id}",
|
||
"attributes": {"whatsNew": "更新内容..."}}}
|
||
```
|
||
|
||
#### 5.4 关联 Build 到 App Store 版本
|
||
|
||
**关键经验**:App Store Connect 页面的 App Icon 来自关联的 build。如果没有把 build 关联到 App Store 版本,图标显示为空。
|
||
|
||
```
|
||
# 关联 build 到版本
|
||
PATCH /v1/appStoreVersions/{version_id}/relationships/build
|
||
{"data": {"type": "builds", "id": "{build_id}"}}
|
||
```
|
||
|
||
### Step 6: 验证部署状态
|
||
|
||
```python
|
||
# 检查 build 状态
|
||
GET /v1/builds/{build_id}?include=buildBetaDetail
|
||
|
||
# 期望结果:
|
||
# processingState: VALID
|
||
# internalBuildState: IN_BETA_TESTING
|
||
# usesNonExemptEncryption: false
|
||
```
|
||
|
||
### 一键部署脚本模板
|
||
|
||
将以上步骤整合为单次 SSH 调用:
|
||
|
||
```bash
|
||
sshpass -p '<password>' ssh -o PreferredAuthentications=password \
|
||
-o PubkeyAuthentication=no <user>@<host> '
|
||
# 0. Keychain
|
||
security unlock-keychain -p "<password>" ~/Library/Keychains/login.keychain-db
|
||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "<password>" ~/Library/Keychains/login.keychain-db 2>/dev/null
|
||
|
||
# 1. Pull
|
||
cd <repo> && git pull origin develop
|
||
|
||
# 2. Archive
|
||
cd ios && rm -rf ~/Desktop/App.xcarchive ~/Desktop/App-export
|
||
xcodebuild archive -project App.xcodeproj -scheme App \
|
||
-destination "generic/platform=iOS" -configuration Release \
|
||
-archivePath ~/Desktop/App.xcarchive \
|
||
-skipMacroValidation CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||
2>&1 | tail -1
|
||
|
||
# 3. Export + Upload
|
||
xcodebuild -exportArchive \
|
||
-archivePath ~/Desktop/App.xcarchive \
|
||
-exportOptionsPlist /tmp/ExportOptions.plist \
|
||
-exportPath ~/Desktop/App-export \
|
||
-authenticationKeyPath <key_path> \
|
||
-authenticationKeyID <key_id> \
|
||
-authenticationKeyIssuerID <issuer_id> \
|
||
2>&1 | grep -E "Upload|EXPORT|error:" | tail -5
|
||
'
|
||
```
|
||
|
||
---
|
||
|
||
## iOS 部署检查清单
|
||
|
||
部署前逐项确认:
|
||
|
||
- [ ] build number 已递增(`CURRENT_PROJECT_VERSION` in project.yml)
|
||
- [ ] `xcodegen generate` 已运行(新文件已包含在 xcodeproj 中)
|
||
- [ ] 代码已 push 到远程仓库
|
||
- [ ] 构建机可 SSH 访问
|
||
- [ ] Assets.xcassets 包含 1024x1024 App Icon
|
||
- [ ] Info.plist 包含 iPad 四方向支持
|
||
- [ ] Distribution 证书已安装在构建机 Keychain
|
||
|
||
部署后逐项确认:
|
||
|
||
- [ ] Archive 成功
|
||
- [ ] Export + Upload 成功
|
||
- [ ] 合规信息已设置(usesNonExemptEncryption)
|
||
- [ ] 测试说明已填写(whatsNew)
|
||
- [ ] Build 已关联到 App Store 版本
|
||
- [ ] TestFlight 状态为 IN_BETA_TESTING
|
||
- [ ] 测试者收到更新通知
|
||
|
||
---
|
||
|
||
## Docker Staging/Production 部署
|
||
|
||
### 架构概览
|
||
|
||
```
|
||
develop push → Build Image → Push ACR → SSH Deploy (staging) → Health Check
|
||
main push → Build Image → Push ACR → 人工审批 → SSH Deploy (prod) → Health Check
|
||
```
|
||
|
||
| 组件 | 说明 |
|
||
|------|------|
|
||
| 服务器 | 39.104.87.246(阿里云 ECS) |
|
||
| Registry | Aliyun ACR: `crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com` |
|
||
| 镜像 | `xiaoqu-gateway`, `xiaoqu-web` |
|
||
| SSH Key | `~/.ssh/xiaoqu.pem` |
|
||
| 部署方式 | Docker Compose |
|
||
|
||
### 完整部署流程
|
||
|
||
```
|
||
1. 本地构建镜像 → docker build -t <image>:<tag>
|
||
2. 推送到 ACR → docker push <registry>/<image>:<tag>
|
||
3. SSH 到服务器 → docker compose pull + up -d
|
||
4. 健康检查 → curl /health
|
||
5. 通知 → 飞书 Webhook 发送部署结果
|
||
```
|
||
|
||
### Staging 部署(develop 分支自动触发)
|
||
|
||
Push 到 `develop` 分支自动触发 staging 部署。流程:
|
||
|
||
```bash
|
||
# 1. 构建镜像(tag 用 commit SHA 前 8 位)
|
||
TAG=$(git rev-parse --short=8 HEAD)
|
||
REGISTRY=crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com
|
||
|
||
docker build -t $REGISTRY/xiaoqu-gateway:$TAG -f gateway/Dockerfile .
|
||
docker build -t $REGISTRY/xiaoqu-web:$TAG -f web/Dockerfile .
|
||
|
||
# 2. 推送到 ACR
|
||
docker push $REGISTRY/xiaoqu-gateway:$TAG
|
||
docker push $REGISTRY/xiaoqu-web:$TAG
|
||
|
||
# 3. SSH 部署
|
||
ssh -i ~/.ssh/xiaoqu.pem root@39.104.87.246 "
|
||
cd /opt/xiaoqu/staging
|
||
export IMAGE_TAG=$TAG
|
||
docker compose pull
|
||
docker compose up -d
|
||
"
|
||
|
||
# 4. 健康检查
|
||
sleep 10
|
||
curl -sf http://39.104.87.246:8080/health || echo 'Health check failed!'
|
||
```
|
||
|
||
### Production 部署(手动审批)
|
||
|
||
Production 部署需要人工确认,不会自动触发:
|
||
|
||
```bash
|
||
# 使用 build-and-push 脚本
|
||
./scripts/build-and-push.sh prod --detect --deploy --wait --verify
|
||
|
||
# 或手动执行:
|
||
TAG=v1.2.3 # 使用语义化版本号
|
||
REGISTRY=crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com
|
||
|
||
# 构建 + 推送
|
||
docker build -t $REGISTRY/xiaoqu-gateway:$TAG -f gateway/Dockerfile .
|
||
docker build -t $REGISTRY/xiaoqu-web:$TAG -f web/Dockerfile .
|
||
docker push $REGISTRY/xiaoqu-gateway:$TAG
|
||
docker push $REGISTRY/xiaoqu-web:$TAG
|
||
|
||
# 部署(生产环境目录)
|
||
ssh -i ~/.ssh/xiaoqu.pem root@39.104.87.246 "
|
||
cd /opt/xiaoqu/production
|
||
export IMAGE_TAG=$TAG
|
||
docker compose pull
|
||
docker compose up -d
|
||
"
|
||
|
||
# 验证
|
||
curl -sf http://39.104.87.246/health && echo 'Production deploy OK'
|
||
```
|
||
|
||
### build-and-push 脚本模板
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# scripts/build-and-push.sh
|
||
set -euo pipefail
|
||
|
||
ENV=${1:-staging}
|
||
REGISTRY=crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com
|
||
SERVER=39.104.87.246
|
||
SSH_KEY=~/.ssh/xiaoqu.pem
|
||
IMAGES=(xiaoqu-gateway xiaoqu-web)
|
||
|
||
# 确定 tag
|
||
if [ "$ENV" = "prod" ]; then
|
||
TAG=${2:-$(git describe --tags --abbrev=0)}
|
||
else
|
||
TAG=$(git rev-parse --short=8 HEAD)
|
||
fi
|
||
|
||
echo "=== Deploying to $ENV with tag $TAG ==="
|
||
|
||
# 构建
|
||
for img in "${IMAGES[@]}"; do
|
||
echo "Building $img..."
|
||
docker build -t $REGISTRY/$img:$TAG -f ${img#xiaoqu-}/Dockerfile .
|
||
done
|
||
|
||
# 推送
|
||
for img in "${IMAGES[@]}"; do
|
||
echo "Pushing $img..."
|
||
docker push $REGISTRY/$img:$TAG
|
||
done
|
||
|
||
# 部署
|
||
DEPLOY_DIR=/opt/xiaoqu/$ENV
|
||
ssh -i $SSH_KEY root@$SERVER "
|
||
cd $DEPLOY_DIR
|
||
export IMAGE_TAG=$TAG
|
||
docker compose pull
|
||
docker compose up -d --remove-orphans
|
||
"
|
||
|
||
# 健康检查(重试 3 次)
|
||
echo "Waiting for health check..."
|
||
for i in 1 2 3; do
|
||
sleep 5
|
||
if curl -sf http://$SERVER/health > /dev/null 2>&1; then
|
||
echo "✓ Health check passed"
|
||
exit 0
|
||
fi
|
||
echo "Attempt $i failed, retrying..."
|
||
done
|
||
|
||
echo "✗ Health check failed after 3 attempts"
|
||
exit 1
|
||
```
|
||
|
||
### Docker Compose 示例
|
||
|
||
```yaml
|
||
# docker-compose.yml
|
||
version: "3.8"
|
||
services:
|
||
gateway:
|
||
image: crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com/xiaoqu-gateway:${IMAGE_TAG:-latest}
|
||
ports:
|
||
- "8080:8080"
|
||
environment:
|
||
- DATABASE_URL=postgres://...
|
||
healthcheck:
|
||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||
interval: 30s
|
||
timeout: 10s
|
||
retries: 3
|
||
restart: unless-stopped
|
||
|
||
web:
|
||
image: crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com/xiaoqu-web:${IMAGE_TAG:-latest}
|
||
ports:
|
||
- "3000:3000"
|
||
depends_on:
|
||
gateway:
|
||
condition: service_healthy
|
||
restart: unless-stopped
|
||
```
|
||
|
||
---
|
||
|
||
## 部署前健康检查
|
||
|
||
部署前进行预检,避免部署失败浪费时间。
|
||
|
||
### iOS 预检
|
||
|
||
```bash
|
||
preflight_ios() {
|
||
local errors=0
|
||
|
||
# 检查 Distribution 证书
|
||
if ! security find-identity -v -p codesigning | grep -q "Apple Distribution"; then
|
||
echo "ERROR: Apple Distribution 证书未安装"
|
||
((errors++))
|
||
fi
|
||
|
||
# 检查 Provisioning Profile 有效期
|
||
local profile_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||
if [ -d "$profile_dir" ]; then
|
||
for profile in "$profile_dir"/*.mobileprovision; do
|
||
local expiry
|
||
expiry=$(security cms -D -i "$profile" 2>/dev/null | plutil -extract ExpirationDate raw - 2>/dev/null)
|
||
if [ -n "$expiry" ]; then
|
||
local expiry_epoch
|
||
expiry_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$expiry" "+%s" 2>/dev/null)
|
||
local now_epoch
|
||
now_epoch=$(date "+%s")
|
||
if [ "$expiry_epoch" -lt "$now_epoch" ]; then
|
||
echo "WARNING: Profile 已过期: $(basename "$profile")"
|
||
((errors++))
|
||
fi
|
||
fi
|
||
done
|
||
else
|
||
echo "ERROR: Provisioning Profiles 目录不存在"
|
||
((errors++))
|
||
fi
|
||
|
||
# 检查 API Key
|
||
if [ ! -f "${API_KEY_PATH:-/dev/null}" ]; then
|
||
echo "ERROR: ASC API Key (.p8) 文件不存在: $API_KEY_PATH"
|
||
((errors++))
|
||
fi
|
||
|
||
# 检查 Xcode
|
||
if ! xcode-select -p > /dev/null 2>&1; then
|
||
echo "ERROR: Xcode Command Line Tools 未安装"
|
||
((errors++))
|
||
fi
|
||
|
||
if [ $errors -gt 0 ]; then
|
||
echo "iOS 预检失败: $errors 个问题"
|
||
return 1
|
||
fi
|
||
echo "iOS 预检通过"
|
||
return 0
|
||
}
|
||
```
|
||
|
||
### Docker 预检
|
||
|
||
```bash
|
||
preflight_docker() {
|
||
local errors=0
|
||
|
||
# 检查 Docker daemon
|
||
if ! docker info > /dev/null 2>&1; then
|
||
echo "ERROR: Docker daemon 未运行"
|
||
((errors++))
|
||
fi
|
||
|
||
# 检查 ACR registry 可达
|
||
local registry=crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com
|
||
if ! docker login $registry --username dummy --password dummy 2>&1 | grep -qv "connection refused"; then
|
||
# login 会失败但不应该是 connection refused
|
||
echo "WARNING: ACR registry 可能不可达(将在 push 时验证)"
|
||
fi
|
||
|
||
# 检查 SSH 连通性
|
||
if ! ssh -i ~/.ssh/xiaoqu.pem -o ConnectTimeout=5 -o BatchMode=yes root@39.104.87.246 "echo ok" > /dev/null 2>&1; then
|
||
echo "ERROR: 无法 SSH 连接到部署服务器 39.104.87.246"
|
||
((errors++))
|
||
fi
|
||
|
||
# 检查服务器磁盘空间
|
||
local disk_usage
|
||
disk_usage=$(ssh -i ~/.ssh/xiaoqu.pem root@39.104.87.246 "df -h / | tail -1 | awk '{print \$5}' | tr -d '%'" 2>/dev/null)
|
||
if [ -n "$disk_usage" ] && [ "$disk_usage" -gt 85 ]; then
|
||
echo "WARNING: 服务器磁盘使用率 ${disk_usage}%(建议清理 docker system prune)"
|
||
fi
|
||
|
||
# 检查本地磁盘空间
|
||
local local_disk
|
||
local_disk=$(df -h . | tail -1 | awk '{print $5}' | tr -d '%')
|
||
if [ "$local_disk" -gt 90 ]; then
|
||
echo "ERROR: 本地磁盘使用率 ${local_disk}%,空间不足"
|
||
((errors++))
|
||
fi
|
||
|
||
if [ $errors -gt 0 ]; then
|
||
echo "Docker 预检失败: $errors 个问题"
|
||
return 1
|
||
fi
|
||
echo "Docker 预检通过"
|
||
return 0
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 回滚策略
|
||
|
||
### iOS TestFlight 回滚
|
||
|
||
TestFlight **无法真正回滚**已安装的版本,但有以下应急手段:
|
||
|
||
| 手段 | 说明 | API |
|
||
|------|------|-----|
|
||
| 停止分发 | 将 build 从测试中移除,用户不再收到更新 | `PATCH /v1/builds/{id}` 设置 `expired: true` |
|
||
| 过期 build | 强制过期有问题的 build | 同上 |
|
||
| 紧急热修 | 构建新版本覆盖上线 | 常规部署流程 |
|
||
|
||
```bash
|
||
# 通过 ASC API 停止分发某个 build
|
||
curl -X PATCH "https://api.appstoreconnect.apple.com/v1/builds/$BUILD_ID" \
|
||
-H "Authorization: Bearer $JWT_TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"data":{"type":"builds","id":"'$BUILD_ID'","attributes":{"expired":true}}}'
|
||
```
|
||
|
||
### Docker 回滚
|
||
|
||
Docker 回滚相对简单,拉取上一个正常版本的镜像重新部署即可:
|
||
|
||
```bash
|
||
# 1. 确定上一个正常的 tag
|
||
PREVIOUS_TAG=<previous-good-tag>
|
||
REGISTRY=crpi-q4nnuivosic0zc98.cn-beijing.personal.cr.aliyuncs.com
|
||
|
||
# 2. 在服务器上回滚
|
||
ssh -i ~/.ssh/xiaoqu.pem root@39.104.87.246 "
|
||
cd /opt/xiaoqu/production # 或 /opt/xiaoqu/staging
|
||
export IMAGE_TAG=$PREVIOUS_TAG
|
||
docker compose pull
|
||
docker compose up -d
|
||
"
|
||
|
||
# 3. 验证回滚成功
|
||
curl -sf http://39.104.87.246/health && echo 'Rollback OK'
|
||
```
|
||
|
||
### 数据库回滚注意事项
|
||
|
||
| 场景 | 策略 |
|
||
|------|------|
|
||
| 可逆 migration(加列、加表) | 部署回滚后数据库无需回滚,旧代码忽略新列 |
|
||
| 不可逆 migration(删列、改类型) | **必须先回滚 migration 再回滚代码**,否则旧代码报错 |
|
||
| 数据 migration | 评估是否需要补偿脚本,建议 migration 前做备份快照 |
|
||
|
||
```bash
|
||
# 数据库 migration 回滚示例(如果使用 golang-migrate)
|
||
ssh -i ~/.ssh/xiaoqu.pem root@39.104.87.246 "
|
||
docker compose exec gateway migrate -path /migrations -database \$DATABASE_URL down 1
|
||
"
|
||
```
|
||
|
||
---
|
||
|
||
## 部署监控
|
||
|
||
### Post-deploy 健康检查模式
|
||
|
||
```bash
|
||
# 通用部署后验证函数
|
||
post_deploy_verify() {
|
||
local url=$1
|
||
local max_retries=${2:-5}
|
||
local interval=${3:-10}
|
||
|
||
echo "Verifying deployment at $url ..."
|
||
for i in $(seq 1 $max_retries); do
|
||
local status
|
||
status=$(curl -sf -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||
if [ "$status" = "200" ]; then
|
||
echo "Health check passed (attempt $i/$max_retries)"
|
||
return 0
|
||
fi
|
||
echo "Attempt $i/$max_retries: status=$status, retrying in ${interval}s..."
|
||
sleep $interval
|
||
done
|
||
echo "Health check FAILED after $max_retries attempts"
|
||
return 1
|
||
}
|
||
|
||
# 使用示例
|
||
post_deploy_verify "http://39.104.87.246/health" 5 10
|
||
```
|
||
|
||
### 飞书通知模板
|
||
|
||
部署完成后通过飞书 Webhook 发送通知:
|
||
|
||
```bash
|
||
# 部署成功通知
|
||
send_feishu_deploy_notification() {
|
||
local env=$1 # staging / production
|
||
local version=$2 # 版本号或 tag
|
||
local status=$3 # success / failure
|
||
local detail=$4 # 额外说明
|
||
|
||
local WEBHOOK_URL="<飞书群 Webhook 地址>"
|
||
|
||
if [ "$status" = "success" ]; then
|
||
local color="green"
|
||
local emoji="✅"
|
||
else
|
||
local color="red"
|
||
local emoji="❌"
|
||
fi
|
||
|
||
curl -s -X POST "$WEBHOOK_URL" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"msg_type": "interactive",
|
||
"card": {
|
||
"header": {
|
||
"title": {"tag": "plain_text", "content": "'"$emoji"' 部署通知 - '"$env"'"},
|
||
"template": "'"$color"'"
|
||
},
|
||
"elements": [
|
||
{"tag": "div", "text": {"tag": "lark_md", "content": "**环境**: '"$env"'\n**版本**: '"$version"'\n**状态**: '"$status"'\n**时间**: '"$(date '+%Y-%m-%d %H:%M:%S')"'\n**详情**: '"$detail"'"}}
|
||
]
|
||
}
|
||
}'
|
||
}
|
||
|
||
# 使用示例
|
||
send_feishu_deploy_notification "production" "v1.2.3" "success" "Gateway + Web 部署完成"
|
||
send_feishu_deploy_notification "staging" "abc12345" "failure" "Health check 超时"
|
||
```
|
||
|
||
### iOS TestFlight 构建状态监控
|
||
|
||
通过 ASC API 持续监控 build 处理状态:
|
||
|
||
```bash
|
||
# 监控 TestFlight build 处理状态
|
||
monitor_testflight_build() {
|
||
local build_id=$1
|
||
local jwt_token=$2
|
||
local max_wait=600 # 最长等待 10 分钟
|
||
local elapsed=0
|
||
|
||
while [ $elapsed -lt $max_wait ]; do
|
||
local response
|
||
response=$(curl -s "https://api.appstoreconnect.apple.com/v1/builds/$build_id" \
|
||
-H "Authorization: Bearer $jwt_token")
|
||
|
||
local state
|
||
state=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['attributes']['processingState'])" 2>/dev/null)
|
||
|
||
echo "[$(date '+%H:%M:%S')] Build $build_id: $state"
|
||
|
||
case "$state" in
|
||
VALID)
|
||
echo "Build 处理完成,可用于测试"
|
||
return 0
|
||
;;
|
||
FAILED|INVALID)
|
||
echo "Build 处理失败: $state"
|
||
return 1
|
||
;;
|
||
PROCESSING)
|
||
sleep 30
|
||
((elapsed+=30))
|
||
;;
|
||
*)
|
||
sleep 15
|
||
((elapsed+=15))
|
||
;;
|
||
esac
|
||
done
|
||
|
||
echo "Build 处理超时(${max_wait}s)"
|
||
return 1
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 与需求工作流集成
|
||
|
||
部署完成后更新需求状态:
|
||
|
||
```bash
|
||
# 推进到 released
|
||
ai-proj req advance --id <req_id> --to released
|
||
|
||
# 创建部署任务并关联
|
||
ai-proj task create --title "【部署】TestFlight 发布: {需求标题}"
|
||
ai-proj req link --id <req_id> --task-ids <task_id>
|
||
|
||
# 附加部署文档
|
||
ai-proj task append-doc --id <task_id> --content "部署记录..."
|
||
```
|
||
|
||
---
|
||
|
||
## 经验教训汇总
|
||
|
||
### iOS TestFlight 部署的 10 个坑
|
||
|
||
| # | 坑 | 解决方案 |
|
||
|---|-----|---------|
|
||
| 1 | SSH 远程 codesign 失败 | `unlock-keychain` + `set-key-partition-list` |
|
||
| 2 | SPM 依赖报签名错误 | Archive 阶段 `CODE_SIGNING_ALLOWED=NO`,Export 阶段签名 |
|
||
| 3 | Swift Macros 被拒 | `-skipMacroValidation` |
|
||
| 4 | xcodeproj 缺文件 | 新增源文件后必须 `xcodegen generate` |
|
||
| 5 | 无 Distribution 证书 | Xcode > Accounts 登录 Apple ID 自动下载 |
|
||
| 6 | build number 冲突 | 每次部署前递增 `CURRENT_PROJECT_VERSION` |
|
||
| 7 | 缺 App Icon | Assets.xcassets + AppIcon.appiconset + 1024x1024 PNG |
|
||
| 8 | iPad 方向验证失败 | 声明四方向或 `UIRequiresFullScreen=true` |
|
||
| 9 | ASC API 401 | JWT 必须包含 `kid` header + 正确的 Issuer ID |
|
||
| 10 | App Store 图标为空 | 需将 build 关联到 App Store 版本(PATCH relationships/build) |
|
||
| 11 | SSH 长连接断开 | xcodebuild 3-4 分钟无输出,Tailscale 断连。用 nohup 后台执行 |
|
||
| 12 | xcodegen 后 cwd 错乱 | `cd ios && xcodegen && cd ..` 失败时不回退。用 subshell `(cd ios && xcodegen)` |
|