#!/usr/bin/env bash # mac_setup.sh — CI runner 自举件(Mac 本机 host 模式;2026-11-03 用户裁,取代 Docker 镜像路线)。 # 一条命令把一台 macOS 变成 gplan 的远端 CI runner:专用低权限账户+Godot+gdstyle+Pillow+act_runner 常驻。 # 用法(sudo 必须——建账户与写 /Library/LaunchDaemons 都要 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 已就位(gitea.com gitea/runner # releases 的 darwin 包,版本与注册时一致)且该目录含已注册的 .runner(注册动作一次性,见 Gitea 文档)。 # 安全设计(用户裁「收紧 runner 权限」): # - 所有作业以专用账户 ci-runner 运行:无 admin、无 sudo、可写面仅其家目录与仓库工作区; # 与日常账户隔离——测试代码摸不到日常环境(威胁模型=测试写坏环境,非恶意第三方件)。 # - runner 以 LaunchDaemon(系统级)常驻:开机自启、不依赖任何人登录、崩溃自动拉起。 # 幂等:重复执行只补缺件,不重复建账户/不重复下载(本地已有同版本产物即跳过)。 set -euo pipefail GODOT_ZIP="" while [ $# -gt 0 ]; do case "$1" in --godot-zip) GODOT_ZIP="${2:-}"; shift 2 ;; *) die "未知参数: $1(用法见头注)" ;; esac done GODOT_VER="4.7.2-stable" GODOT_URL_BASE="https://github.com/godotengine/godot/releases/download/${GODOT_VER}-stable/Godot_${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}" log() { echo "[mac_setup] $*"; } die() { echo "[mac_setup] ✗ $*" >&2; exit 1; } [ "$(uname -s)" = "Darwin" ] || die "仅支持 macOS" [ "$(id -u)" -eq 0 ] || die "请用 sudo 运行(建账户/写 LaunchDaemon 需要 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 包(版本须与 .runner 注册时一致)" [ -f "${RUNNER_SRC}/.runner" ] || die "缺 ${RUNNER_SRC}/.runner——act_runner 尚未注册(register 子命令,见 Gitea 文档)" # ── 1. 专用账户(sysadminctl 一条建全:随机未告知密码=无法交互登录,纯服务账户)── if dscl . -read "${CI_HOME}" NFSHomeDirectory >/dev/null 2>&1; then log "账户 ${CI_USER} 已存在,跳过创建" else _randpw="$(uuidgen | tr -d '-')" /usr/sbin/sysadminctl -addUser "$CI_USER" -password "$_randpw" -home "$CI_HOME" \ || die "账户创建失败(sysadminctl)——检查磁盘/SecureToken 设置" log "账户 ${CI_USER} 已创建(随机密码未落盘=无法交互登录,纯服务账户)" fi # ── 2. Godot macOS 版 → /usr/local/bin/godot ── 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="$GODOT_ZIP" SRC_ZIP="/Users/${INVOKER}/godot-linux/Godot_${GODOT_VER}-stable_macos.universal.zip" if [ -n "$ZIP" ] && [ -s "$ZIP" ]; then log "使用本地 zip: $ZIP" elif [ -z "$ZIP" ] && [ -s "$SRC_ZIP" ]; then log "复用已下载 zip: $SRC_ZIP" ZIP="$SRC_ZIP" else ZIP="$(mktemp "${TMPDIR:-/tmp}/godot_mac_XXXXXX.zip")" _ok=0 for m in "${MIRRORS[@]}"; do url="${m}${GODOT_URL_BASE}" log "下载 Godot(${m:+经 $m}):${GODOT_URL_BASE}" if curl -fL --retry 2 -m 1800 -o "$ZIP" "$url"; then _ok=1; break; fi log "该源失败,换下一源" done [ "$_ok" = 1 ] || die "全部下载源失败——请手动下载 ${GODOT_URL_BASE} 后重跑本脚本加 --godot-zip " fi TMPD="$(mktemp -d)" unzip -q -o "$ZIP" -d "$TMPD" install -m 0755 "${TMPD}/Godot.app/Contents/MacOS/Godot" /usr/local/bin/godot xattr -dr com.apple.quarantine /usr/local/bin/godot 2>/dev/null || true rm -rf "$TMPD" log "Godot 已装:$(/usr/local/bin/godot --version 2>/dev/null | head -1)" fi # ── 3. 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.tar.gz")" _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}"; then _ok=1; break; fi done [ "$_ok" = 1 ] || die "gdstyle 下载失败——手动下载 ${GDSTYLE_URL} 解压后放 /usr/local/bin/gdstyle 再重跑" 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 # ── 4. Pillow(渲染闸 shot_checker/golden_compare 依赖,装给 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(阿里源)" sudo -u "$CI_USER" python3 -m pip install --user pillow \ -i https://mirrors.aliyun.com/pypi/simple/ --quiet \ || die "Pillow 安装失败" fi # ── 5. runner 程序与注册文件就位(复制既有注册身份,免重新注册)── mkdir -p "${CI_HOME}/gitea-runner" 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 # 标签切 host 模式:作业直接跑在本机(.runner 的 labels 是声明面,daemon 启动时重新 declare) 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" # ── 6. LaunchDaemon 常驻(系统级:开机自启、不依赖登录、崩溃自动拉起)── PLIST="/Library/LaunchDaemons/com.gitea.act-runner.plist" cat > "$PLIST" < Labelcom.gitea.act-runner UserName${CI_USER} GroupNamestaff 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 launchctl bootout system/com.gitea.act-runner 2>/dev/null || true launchctl bootstrap system "$PLIST" || launchctl load -w "$PLIST" log "LaunchDaemon 已加载(com.gitea.act-runner,用户 ${CI_USER})" # ── 7. 退役 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 # ── 8. 验证 ── sleep 5 if launchctl print "system/com.gitea.act-runner" 2>/dev/null | grep -q "state"; then log "runner 服务在跑" else die "runner 服务未起来——查 ${CI_HOME}/gitea-runner/daemon.log" fi log "全部完成。验证清单:" log " ① Gitea 网页 → 仓库设置 → Actions → Runners 应显示 mac-ci 在线(Idle)" log " ② sudo -u ${CI_USER} /usr/local/bin/godot --headless --version" log " ③ sudo -u ${CI_USER} python3 -c 'import PIL'" log " ④ 任意 push 触发一次跑批端到端验证"