#!/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 # 家目录兜底:sysadminctl 只登记不创建(首次运行输出 "not created!" 在案)——家目录缺失时 # 自动登录与任何以该用户执行的操作都会失败(Pillow --user 失败即此根因)。 if [ ! -d "${CI_HOME}" ]; then mkdir -p "${CI_HOME}" chown -R "${CI_USER}:staff" "${CI_HOME}" createhomedir -c -u "$CI_USER" >/dev/null 2>&1 || true log "家目录 ${CI_HOME} 已补建" 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(渲染闸依赖)——系统级安装(/Library/Python,全账户可导入), # 不用 --user:ci-runner 家目录时序敏感,系统级绕开全部家目录问题 ── if python3 -c "import PIL" >/dev/null 2>&1; then log "Pillow 已在位(系统级),跳过" else log "系统级安装 Pillow(阿里源)" python3 -m pip install 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" < Labelcom.gitea.act-runner ProgramArguments ${CI_HOME}/gitea-runner/act_runner daemon WorkingDirectory${CI_HOME}/gitea-runner RunAtLoad KeepAlive StandardOutPath${CI_HOME}/gitea-runner/daemon.log StandardErrorPath${CI_HOME}/gitea-runner/daemon.log 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 的推送自动触发跑批"