Files

22 KiB
Raw Permalink Blame History

name, description
name description
dev-deploy 应用部署技能。支持 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 本机安装用于非交互 SSHbrew 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 连接构建机

# 使用 sshpass 进行非交互 SSH
sshpass -p '<password>' ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no <user>@<host> '<command>'

关键经验

  • SSH 远程 codesign 需要先解锁 Keychain,否则报 errSecInternalComponent
  • 还需要 set-key-partition-list 授权 codesign 访问密钥
# 必须在每次 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: 拉取代码

cd <repo-path> && git pull origin develop

Step 3: Archive无签名

关键经验Archive 阶段不要签名。原因:

  • xcodebuild CLI 签名参数会泄漏到 SPM 依赖的 targets导致 "does not support provisioning profiles" 错误
  • 正确做法是 archive 时禁用签名,在 export 阶段单独签名
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

# 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

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: 验证部署状态

# 检查 build 状态
GET /v1/builds/{build_id}?include=buildBetaDetail

# 期望结果:
# processingState: VALID
# internalBuildState: IN_BETA_TESTING
# usesNonExemptEncryption: false

一键部署脚本模板

将以上步骤整合为单次 SSH 调用:

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 部署。流程:

# 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 部署需要人工确认,不会自动触发:

# 使用 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 脚本模板

#!/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 示例

# 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 预检

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 预检

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 同上
紧急热修 构建新版本覆盖上线 常规部署流程
# 通过 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 回滚相对简单,拉取上一个正常版本的镜像重新部署即可:

# 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 前做备份快照
# 数据库 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 健康检查模式

# 通用部署后验证函数
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 发送通知:

# 部署成功通知
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 处理状态:

# 监控 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
}

与需求工作流集成

部署完成后更新需求状态:

# 推进到 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=NOExport 阶段签名
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)