Files
gplan-2/tools/ci/mac_setup.sh
T

226 lines
11 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# mac_setup.sh — CI runner 自举件(Mac 本机 host 模式+专用账户图形会话;2026-11-03 用户裁)。
# 一条命令把一台 macOS 变成 gplan 的远端 CI runner:专用账户+自动登录图形会话+Godot+gdstyle+
# Pillow+act_runner 用户级常驻(LaunchAgent)。
# 形态要点(渲染闸的硬需求):渲染闸要真窗口 = 必须有图形会话(WindowServer 只存在于已登录会话)。
# 方案 = ci-runner 账户开机自动登录(owns 控制台图形会话)→ 该会话内 LaunchAgent 拉起 runner →
# 测试作业全部以 ci-runner 身份运行(无 admin、可写面限定);日常使用经「快速用户切换」到本人账户,
# 两会话并存互不干扰(后台会话保留约 1-2GB 内存;后台会话渲染行为在端到端验收第一轮实证)。
# 用法(sudo 必须——建账户/写 /etc/kcpassword/装 /usr/local/bin 都要 root):
# sudo bash tools/ci/mac_setup.sh [--godot-zip <本地已下载的 macOS 版 zip 路径>]
# 环境前提:
# ① macOS(Intel/Apple Silicon 皆可,Godot 走 universal 包)② Xcode Command Line Tools 已装
# (python3/git 在位;缺则先 xcode-select --install)③ 调用账户的 ~/gitea-runner/ 下已有
# act_runner(darwin 包,gitea.com gitea/runner releases)与已注册的 .runner(一次性注册动作)。
# 幂等:重复执行只补缺件;账户密码首次生成后存 /var/db/gplan-ci-runner.pw(root 600),重跑复用。
set -euo pipefail
GODOT_VER="4.7.2"
GODOT_URL="https://github.com/godotengine/godot/releases/download/${GODOT_VER}-stable/Godot_v${GODOT_VER}-stable_macos.universal.zip"
GDSTYLE_VER="0.4.0"
GDSTYLE_URL="https://github.com/atelico/gdstyle/releases/download/v${GDSTYLE_VER}/gdstyle-x86_64-apple-darwin.tar.gz"
MIRRORS=("" "https://gh-proxy.net/" "https://gh-proxy.com/") # 直连优先,镜像回落
CI_USER="ci-runner"
CI_HOME="/Users/${CI_USER}"
PW_FILE="/var/db/gplan-ci-runner.pw"
log() { echo "[mac_setup] $*"; }
die() { echo "[mac_setup] ✗ $*" >&2; exit 1; }
[ "$(uname -s)" = "Darwin" ] || die "仅支持 macOS"
[ "$(id -u)" -eq 0 ] || die "请用 sudo 运行(建账户/写 /etc/kcpassword/装 /usr/local/bin 需要 root)"
for t in git curl python3 unzip dscl; do command -v "$t" >/dev/null 2>&1 || die "缺 $t(先 xcode-select --install)"; done
INVOKER="${SUDO_USER:-$(whoami)}"
RUNNER_SRC="/Users/${INVOKER}/gitea-runner"
[ -x "${RUNNER_SRC}/act_runner" ] || die "缺 ${RUNNER_SRC}/act_runner——先从 gitea.com/gitea/runner releases 下载 darwin 包"
[ -f "${RUNNER_SRC}/.runner" ] || die "缺 ${RUNNER_SRC}/.runner——act_runner 尚未注册(register 子命令,见 Gitea 文档)"
GODOT_ZIP=""
while [ $# -gt 0 ]; do
case "$1" in
--godot-zip) GODOT_ZIP="${2:-}"; shift 2 ;;
*) die "未知参数: $1" ;;
esac
done
rm -f /tmp/godot_mac_XXXXXX.zip /tmp/gdstyle_XXXXXX.tar.gz 2>/dev/null || true # 早期 BSD-mktemp 缺陷的字面残留清理
# ── 1. 专用账户+密码落盘(自动登录需要密码编码进 /etc/kcpassword,root 600)──
if dscl . -read "${CI_HOME}" NFSHomeDirectory >/dev/null 2>&1; then
log "账户 ${CI_USER} 已存在"
[ -f "$PW_FILE" ] || die "账户已存在但 $PW_FILE 缺失(无法配置自动登录)——删除账户重跑,或手工补密码文件"
CI_PW="$(cat "$PW_FILE")"
else
CI_PW="$(uuidgen | tr -d '-')"
/usr/sbin/sysadminctl -addUser "$CI_USER" -password "$CI_PW" -home "$CI_HOME" \
|| die "账户创建失败(sysadminctl)"
printf '%s' "$CI_PW" > "$PW_FILE"
chmod 600 "$PW_FILE"
log "账户 ${CI_USER} 已创建(密码仅存 ${PW_FILE},root 600,不对外告知)"
fi
# ── 2. 自动登录(ci-runner 拥有控制台图形会话——渲染闸的窗口来源)──
_KCP="/etc/kcpassword"
_need_kcp=1
[ -f "$_KCP" ] && _need_kcp=0
if defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null | grep -q "$CI_USER"; then
log "自动登录已指向 ${CI_USER},跳过"
_need_kcp=0
fi
if [ "$_need_kcp" = 1 ]; then
python3 - "$CI_PW" <<'PYEOF'
import sys
pwd = sys.argv[1].encode()
key = bytes([0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F])
enc = bytes(b ^ key[i % len(key)] for i, b in enumerate(pwd))
enc += b"\x00" * ((12 - len(enc) % 12) % 12 + 1)
open("/etc/kcpassword", "wb").write(enc)
PYEOF
chmod 600 /etc/kcpassword
defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser "$CI_USER"
log "自动登录已配置:开机直接进入 ${CI_USER} 图形会话(日常使用=快速用户切换到本人账户)"
fi
# ── 3. Godot macOS 版 → 完整 .app 装 /usr/local/lib + /usr/local/bin/godot 包装转发 ──
# 纪律:官方签名封 bundle 的 Info.plist——从 .app 抽裸二进制会被 taskgated 判
# 「Code Signature Invalid」SIGKILL(2026-11-03 实证),必须整 bundle 安装保持签名校验路径。
if /usr/local/bin/godot --version >/dev/null 2>&1 && /usr/local/bin/godot --version 2>/dev/null | grep -q "$GODOT_VER"; then
log "Godot ${GODOT_VER} 已在位,跳过"
else
ZIP=""
SRC_ZIP="/Users/${INVOKER}/godot-linux/Godot_v${GODOT_VER}-stable_macos.universal.zip"
if [ -n "$GODOT_ZIP" ] && [ -s "$GODOT_ZIP" ]; then
ZIP="$GODOT_ZIP"; log "使用本地 zip: $ZIP"
elif [ -z "$GODOT_ZIP" ] && [ -s "$SRC_ZIP" ]; then
ZIP="$SRC_ZIP"; log "复用已下载 zip: $ZIP"
else
ZIP="$(mktemp "${TMPDIR:-/tmp}/godot_mac_XXXXXX")" # macOS BSD mktemp 禁 X 后带后缀
_ok=0
for m in "${MIRRORS[@]}"; do
log "下载 Godot(${m:+经 $m})"
# 镜像站可能回 200 的错误页体——下载后必须验 zip 结构,假成功当失败处理
if curl -fL --retry 2 -m 1800 -o "$ZIP" "${m}${GODOT_URL}" && unzip -tq "$ZIP" >/dev/null 2>&1; then
_ok=1; break
fi
log "该源失败,换下一源"
done
[ "$_ok" = 1 ] || die "全部下载源失败——手动下载 ${GODOT_URL} 后重跑并加 --godot-zip <路径>;或用 tools/ci/chunk_dl.py 分片拉取"
fi
TMPD="$(mktemp -d)"
unzip -tq "$ZIP" >/dev/null # 双保险:本地复用的 zip 同样验结构
unzip -q -o "$ZIP" -d "$TMPD"
mkdir -p /usr/local/lib
rm -rf "/usr/local/lib/Godot_ci.app"
cp -R "${TMPD}/Godot.app" "/usr/local/lib/Godot_ci.app"
xattr -dr com.apple.quarantine "/usr/local/lib/Godot_ci.app" 2>/dev/null || true
cat > /usr/local/bin/godot <<'EOF'
#!/bin/bash
exec /usr/local/lib/Godot_ci.app/Contents/MacOS/Godot "$@"
EOF
chmod 0755 /usr/local/bin/godot
rm -rf "$TMPD"
log "Godot 已装:$(/usr/local/bin/godot --version 2>/dev/null | head -1)"
fi
# ── 4. gdstyle v0.4.0 darwin → /usr/local/bin/gdstyle(S13 静态扫描闸件,版本钉死)──
if /usr/local/bin/gdstyle --version >/dev/null 2>&1; then
log "gdstyle 已在位,跳过"
else
TGZ="$(mktemp "${TMPDIR:-/tmp}/gdstyle_XXXXXX")" # macOS BSD mktemp 禁 X 后带后缀
SRC_TGZ="/Users/${INVOKER}/godot-linux/gdstyle-x86_64-apple-darwin.tar.gz"
if [ -s "$SRC_TGZ" ] && tar -tzf "$SRC_TGZ" >/dev/null 2>&1; then
TGZ="$SRC_TGZ"; log "复用已下载 gdstyle 包: $TGZ"
else
_ok=0
for m in "${MIRRORS[@]}"; do
log "下载 gdstyle v${GDSTYLE_VER} darwin(${m:+经 $m})"
if curl -fL --retry 2 -m 600 -o "$TGZ" "${m}${GDSTYLE_URL}" && tar -tzf "$TGZ" >/dev/null 2>&1; then _ok=1; break; fi
done
[ "$_ok" = 1 ] || die "gdstyle 下载失败——手动下载 ${GDSTYLE_URL} 解压放 /usr/local/bin/gdstyle 后重跑"
fi
TMPD="$(mktemp -d)"
tar -xzf "$TGZ" -C "$TMPD"
BIN="$(find "$TMPD" -type f -name 'gdstyle*' | head -1)"
[ -n "$BIN" ] || die "压缩包内未找到 gdstyle 二进制"
install -m 0755 "$BIN" /usr/local/bin/gdstyle
xattr -dr com.apple.quarantine /usr/local/bin/gdstyle 2>/dev/null || true
rm -rf "$TMPD"
log "gdstyle 已装:/usr/local/bin/gdstyle"
fi
# ── 5. Pillow(渲染闸依赖,装给 ci-runner 用户)──
if sudo -u "$CI_USER" python3 -c "import PIL" >/dev/null 2>&1; then
log "Pillow 已在位(ci-runner),跳过"
else
log "为 ci-runner 安装 Pillow(阿里源;-H 修 HOME——sudo -u 默认保留调用者 HOME 会写错缓存目录)"
sudo -H -u "$CI_USER" python3 -m pip install --user pillow \
-i https://mirrors.aliyun.com/pypi/simple/ --quiet \
|| die "Pillow 安装失败"
fi
# ── 6. runner 程序与注册文件就位(复制既有注册身份,免重新注册)──
mkdir -p "${CI_HOME}/gitea-runner" "${CI_HOME}/Library/LaunchAgents"
cp -f "${RUNNER_SRC}/act_runner" "${CI_HOME}/gitea-runner/act_runner"
cp -f "${RUNNER_SRC}/.runner" "${CI_HOME}/gitea-runner/.runner"
cp -f "${RUNNER_SRC}/config.yaml" "${CI_HOME}/gitea-runner/config.yaml" 2>/dev/null || true
python3 - "${CI_HOME}/gitea-runner/.runner" <<'PYEOF'
import json, sys
p = sys.argv[1]
d = json.load(open(p))
d["labels"] = ["ci-mac:host"]
json.dump(d, open(p, "w"))
print("labels ->", d["labels"])
PYEOF
sed -i '' 's/^ level: debug/ level: info/' "${CI_HOME}/gitea-runner/config.yaml" 2>/dev/null || true
chown -R "${CI_USER}:staff" "${CI_HOME}/gitea-runner" "${CI_HOME}/Library/LaunchAgents"
# ── 7. LaunchAgent(用户级常驻,挂在 ci-runner 图形会话内——渲染闸窗口的来源)──
PLIST="${CI_HOME}/Library/LaunchAgents/com.gitea.act-runner.plist"
cat > "$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>Label</key><string>com.gitea.act-runner</string>
<key>ProgramArguments</key>
<array>
<string>${CI_HOME}/gitea-runner/act_runner</string>
<string>daemon</string>
</array>
<key>WorkingDirectory</key><string>${CI_HOME}/gitea-runner</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${CI_HOME}/gitea-runner/daemon.log</string>
<key>StandardErrorPath</key><string>${CI_HOME}/gitea-runner/daemon.log</string>
</dict>
</plist>
EOF
chown "${CI_USER}:staff" "$PLIST"
_uid="$(id -u "$CI_USER")"
if launchctl asuser "$_uid" launchctl bootstrap "gui/$_uid" "$PLIST" 2>/dev/null; then
log "LaunchAgent 已在 ${CI_USER} 会话内启动"
_started=1
else
_started=0
log "当前无 ${CI_USER} 图形会话——LaunchAgent 已就位,重启后随自动登录自动拉起"
fi
# ── 8. 退役 Docker 版 runner(如存在)──
if command -v docker >/dev/null 2>&1; then
docker rm -f gitea-runner >/dev/null 2>&1 && log "Docker 版 runner 容器已退役" || log "无 Docker 版 runner 容器(或 docker 未运行)"
fi
# ── 9. 验证与收尾提示 ──
if [ "$_started" = 1 ]; then
sleep 5
grep -q "declare successfully" "${CI_HOME}/gitea-runner/daemon.log" 2>/dev/null \
&& log "runner 已向 Gitea 声明成功" || log "runner 已启动,声明确认见 ${CI_HOME}/gitea-runner/daemon.log"
else
log "⚠ 重启 Mac 后自动登录生效、runner 随会话拉起——重启后验证第①条"
fi
log "全部完成。验证清单:"
log " ① Gitea 网页 → 仓库设置 → Actions → Runners 应显示 mac-ci 在线(Idle,标签 ci-mac)"
log " ② sudo -u ${CI_USER} /usr/local/bin/godot --headless --version"
log " ③ sudo -u ${CI_USER} python3 -c 'import PIL'"
log " ④ 日常使用:登录窗/快速用户切换到本人账户(ci-runner 会话在后台保持)"
log " ⑤ 端到端:下一次合回 master 的推送自动触发跑批"