diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cdd26912e..ae6ed36cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,8 @@ jobs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -34,168 +36,140 @@ jobs:
- '*.md'
- '.github/**'
- # ==================== 类型检查 ====================
- typecheck:
- runs-on: ubuntu-latest
- timeout-minutes: 10
+ # ==================== 检测 self-hosted runner 可用性 ====================
+ check-runner:
+ runs-on: self-hosted
+ timeout-minutes: 1
+ continue-on-error: true
+ outputs:
+ available: ${{ steps.check.outputs.available }}
steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: ${{ env.NODE_VERSION }}
- cache: pnpm
+ - id: check
+ run: echo "available=true" >> $GITHUB_OUTPUT
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Type check
- run: pnpm typecheck
-
- # ==================== 单元测试 ====================
- test:
- runs-on: ubuntu-latest
+ # ==================== 快速检查 (合并 typecheck + build + i18n) ====================
+ quick-checks:
+ needs: check-runner
+ runs-on: ${{ needs.check-runner.outputs.available == 'true' && 'self-hosted' || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
with:
- node-version: ${{ env.NODE_VERSION }}
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Unit tests
- run: pnpm test
-
- # ==================== i18n 检查 ====================
- i18n:
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
+ fetch-depth: 1
+ # GitHub-hosted: 需要安装环境
- name: Setup Bun
+ if: needs.check-runner.outputs.available != 'true'
uses: oven-sh/setup-bun@v2
-
- name: Setup pnpm
+ if: needs.check-runner.outputs.available != 'true'
uses: pnpm/action-setup@v4
-
- name: Setup Node.js
+ if: needs.check-runner.outputs.available != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
-
+ # 通用步骤
- name: Install dependencies
run: pnpm install --frozen-lockfile
-
+ - name: Type check
+ run: pnpm typecheck
+ - name: Build
+ run: pnpm build
- name: Check i18n keys
run: pnpm i18n:check
- # ==================== 构建检查 ====================
- build:
- runs-on: ubuntu-latest
+ # ==================== 单元测试 (独立运行,耗时长) ====================
+ test:
+ needs: check-runner
+ runs-on: ${{ needs.check-runner.outputs.available == 'true' && 'self-hosted' || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
-
+ with:
+ fetch-depth: 1
- name: Setup pnpm
+ if: needs.check-runner.outputs.available != 'true'
uses: pnpm/action-setup@v4
-
- name: Setup Node.js
+ if: needs.check-runner.outputs.available != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
-
- name: Install dependencies
run: pnpm install --frozen-lockfile
-
- - name: Build
- run: pnpm build
+ - name: Unit tests
+ run: pnpm test
# ==================== E2E 测试(仅代码变更时运行) ====================
e2e:
- needs: changes
+ needs: [changes, check-runner]
if: needs.changes.outputs.code == 'true'
- runs-on: ubuntu-latest
+ runs-on: ${{ needs.check-runner.outputs.available == 'true' && 'self-hosted' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
-
+ with:
+ fetch-depth: 1
- name: Setup Bun
+ if: needs.check-runner.outputs.available != 'true'
uses: oven-sh/setup-bun@v2
-
- name: Setup pnpm
+ if: needs.check-runner.outputs.available != 'true'
uses: pnpm/action-setup@v4
-
- name: Setup Node.js
+ if: needs.check-runner.outputs.available != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
-
- name: Install dependencies
run: pnpm install --frozen-lockfile
-
+ # GitHub-hosted: 需要安装 Playwright
- name: Cache Playwright browsers
+ if: needs.check-runner.outputs.available != 'true'
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
-
- name: Install Playwright browsers
- if: steps.playwright-cache.outputs.cache-hit != 'true'
+ if: needs.check-runner.outputs.available != 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
-
- name: Install Playwright deps (if cached)
- if: steps.playwright-cache.outputs.cache-hit == 'true'
+ if: needs.check-runner.outputs.available != 'true' && steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
-
+ # E2E 测试
- name: E2E smoke (Mobile Chrome)
run: pnpm e2e bioforest-chains --project "Mobile Chrome"
# ==================== 最终状态检查 ====================
checks:
runs-on: ubuntu-latest
- needs: [typecheck, test, i18n, build, e2e]
+ needs: [check-runner, quick-checks, test, e2e]
if: always()
steps:
+ - name: Report runner status
+ run: |
+ if [[ "${{ needs.check-runner.outputs.available }}" == "true" ]]; then
+ echo "🚀 Using self-hosted runner (M1 Mac) - fast mode"
+ else
+ echo "☁️ Using GitHub-hosted runner (ubuntu-latest)"
+ fi
- name: Check results
run: |
- if [[ "${{ needs.typecheck.result }}" == "failure" ]]; then
- echo "Typecheck failed"
+ if [[ "${{ needs.quick-checks.result }}" == "failure" ]]; then
+ echo "Quick checks (typecheck/build/i18n) failed"
exit 1
fi
if [[ "${{ needs.test.result }}" == "failure" ]]; then
echo "Tests failed"
exit 1
fi
- if [[ "${{ needs.i18n.result }}" == "failure" ]]; then
- echo "i18n check failed"
- exit 1
- fi
- if [[ "${{ needs.build.result }}" == "failure" ]]; then
- echo "Build failed"
- exit 1
- fi
if [[ "${{ needs.e2e.result }}" == "failure" ]]; then
echo "E2E failed"
exit 1
diff --git a/scripts/self-hosted/README.md b/scripts/self-hosted/README.md
new file mode 100644
index 000000000..d6ca19370
--- /dev/null
+++ b/scripts/self-hosted/README.md
@@ -0,0 +1,82 @@
+# Self-hosted Runner 配置指南
+
+配置 GitHub Actions self-hosted runner,加速 CI/CD。
+
+## 快速开始
+
+```bash
+# 1. 配置环境 (Node.js, pnpm, bun, Playwright)
+./scripts/self-hosted/env-setup.sh
+
+# 2. 获取 GitHub Runner 注册 token
+TOKEN=$(gh api -X POST repos/BioforestChain/KeyApp/actions/runners/registration-token --jq '.token')
+
+# 3. 配置多个 runner (推荐 4 个)
+./scripts/self-hosted/setup-runners.sh 4 "$TOKEN"
+
+# 4. 启动 runners
+~/actions-runners/start-all.sh
+```
+
+## 脚本说明
+
+| 脚本 | 说明 |
+|------|------|
+| `env-setup.sh` | 安装 Node.js, pnpm, bun, Playwright |
+| `setup-runners.sh` | 配置多个 GitHub Actions runner |
+
+## CI 工作流程
+
+**Self-hosted 可用时 (快速模式):**
+- 跳过环境安装 (预装)
+- 直接: checkout → pnpm install → run
+- 多个 runner 并行执行
+
+**Self-hosted 不可用时 (Fallback):**
+- 自动切换到 GitHub-hosted ubuntu-latest
+- 完整安装流程
+- 确保 CI 不中断
+
+## Runner 管理
+
+脚本生成在 `~/actions-runners/`:
+
+```bash
+~/actions-runners/start-all.sh # 启动
+~/actions-runners/stop-all.sh # 停止
+~/actions-runners/status.sh # 状态
+~/actions-runners/restart-all.sh # 重启
+```
+
+## 开机自启动 (macOS)
+
+```bash
+# 创建 LaunchAgent
+cat > ~/Library/LaunchAgents/com.github.actions-runner.plist << 'EOF'
+
+
+
+
+ Label
+ com.github.actions-runner
+ ProgramArguments
+
+ sh
+ -c
+ $HOME/actions-runners/start-all.sh
+
+ RunAtLoad
+
+
+
+EOF
+
+# 加载
+launchctl load ~/Library/LaunchAgents/com.github.actions-runner.plist
+```
+
+## 系统要求
+
+- macOS (M1/M2) 或 Linux (x64/arm64)
+- 8GB+ RAM (推荐 16GB)
+- 稳定网络
diff --git a/scripts/self-hosted/env-setup.sh b/scripts/self-hosted/env-setup.sh
new file mode 100755
index 000000000..8ba0daaca
--- /dev/null
+++ b/scripts/self-hosted/env-setup.sh
@@ -0,0 +1,131 @@
+#!/bin/bash
+# Self-hosted Runner 环境配置脚本
+# 在 macOS (M1/M2) 上配置 CI 所需的所有环境
+#
+# 使用方法:
+# chmod +x scripts/self-hosted/env-setup.sh
+# ./scripts/self-hosted/env-setup.sh
+#
+# 此脚本会安装:
+# - Node.js 24+ (via volta)
+# - pnpm (via corepack)
+# - bun
+# - Playwright chromium
+
+set -e
+
+echo "=========================================="
+echo " Self-hosted Runner 环境配置"
+echo "=========================================="
+echo ""
+
+# 颜色
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+check_command() {
+ if command -v "$1" &> /dev/null; then
+ echo -e "${GREEN}✓${NC} $1 已安装: $($1 --version 2>&1 | head -1)"
+ return 0
+ else
+ echo -e "${RED}✗${NC} $1 未安装"
+ return 1
+ fi
+}
+
+# ==================== 检查当前环境 ====================
+echo "检查当前环境..."
+echo ""
+
+check_command node || NODE_MISSING=1
+check_command pnpm || PNPM_MISSING=1
+check_command bun || BUN_MISSING=1
+
+echo ""
+
+# ==================== 安装 Volta (Node.js 版本管理) ====================
+if [ "$NODE_MISSING" = "1" ] || ! command -v volta &> /dev/null; then
+ echo "安装 Volta (Node.js 版本管理)..."
+ curl https://get.volta.sh | bash
+ export VOLTA_HOME="$HOME/.volta"
+ export PATH="$VOLTA_HOME/bin:$PATH"
+ echo ""
+fi
+
+# ==================== 安装 Node.js 24 ====================
+if [ "$NODE_MISSING" = "1" ]; then
+ echo "安装 Node.js 24..."
+ if command -v volta &> /dev/null; then
+ volta install node@24
+ elif command -v fnm &> /dev/null; then
+ fnm install 24
+ fnm use 24
+ elif command -v nvm &> /dev/null; then
+ nvm install 24
+ nvm use 24
+ else
+ echo -e "${YELLOW}警告: 请手动安装 Node.js 24${NC}"
+ echo " 推荐使用 volta: https://volta.sh"
+ fi
+ echo ""
+fi
+
+# ==================== 启用 corepack 并安装 pnpm ====================
+if [ "$PNPM_MISSING" = "1" ]; then
+ echo "启用 corepack 并安装 pnpm..."
+ corepack enable
+ corepack prepare pnpm@latest --activate
+ echo ""
+fi
+
+# ==================== 安装 Bun ====================
+if [ "$BUN_MISSING" = "1" ]; then
+ echo "安装 Bun..."
+ curl -fsSL https://bun.sh/install | bash
+ export BUN_INSTALL="$HOME/.bun"
+ export PATH="$BUN_INSTALL/bin:$PATH"
+ echo ""
+fi
+
+# ==================== 安装项目依赖 ====================
+echo "安装项目依赖..."
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+cd "$PROJECT_ROOT"
+pnpm install
+echo ""
+
+# ==================== 安装 Playwright ====================
+echo "安装 Playwright chromium..."
+pnpm exec playwright install chromium
+echo ""
+
+# ==================== 验证安装 ====================
+echo "=========================================="
+echo " 验证安装结果"
+echo "=========================================="
+echo ""
+
+check_command node
+check_command pnpm
+check_command bun
+
+echo ""
+echo "检查 Playwright..."
+if [ -d "$HOME/.cache/ms-playwright" ] || [ -d "$HOME/Library/Caches/ms-playwright" ]; then
+ echo -e "${GREEN}✓${NC} Playwright 浏览器已安装"
+else
+ echo -e "${YELLOW}⚠${NC} Playwright 浏览器可能未安装,请运行: pnpm exec playwright install chromium"
+fi
+
+echo ""
+echo "=========================================="
+echo -e " ${GREEN}环境配置完成!${NC}"
+echo "=========================================="
+echo ""
+echo "下一步: 配置 GitHub Actions Runner"
+echo " ./scripts/self-hosted/setup-runners.sh "
+echo ""
diff --git a/scripts/self-hosted/setup-runners.sh b/scripts/self-hosted/setup-runners.sh
new file mode 100755
index 000000000..248c2aaa2
--- /dev/null
+++ b/scripts/self-hosted/setup-runners.sh
@@ -0,0 +1,208 @@
+#!/bin/bash
+# GitHub Actions Self-hosted Runner 批量配置脚本
+#
+# 使用方法:
+# ./scripts/self-hosted/setup-runners.sh
+#
+# 获取 token:
+# gh api -X POST repos/OWNER/REPO/actions/runners/registration-token --jq '.token'
+#
+# 示例:
+# ./scripts/self-hosted/setup-runners.sh 4 AXXXX...
+
+set -e
+
+NUM_RUNNERS=${1:-4}
+GITHUB_TOKEN=${2:-}
+REPO=${GITHUB_REPOSITORY:-"BioforestChain/KeyApp"}
+RUNNER_VERSION="2.321.0" # https://github.com/actions/runner/releases
+RUNNER_BASE_DIR="$HOME/actions-runners"
+
+echo "=========================================="
+echo " GitHub Actions Self-hosted Runner 配置"
+echo "=========================================="
+echo ""
+echo "Runner 数量: $NUM_RUNNERS"
+echo "仓库: $REPO"
+echo "安装目录: $RUNNER_BASE_DIR"
+echo ""
+
+# 检查 token
+if [ -z "$GITHUB_TOKEN" ]; then
+ echo "请提供 GitHub token 作为第二个参数"
+ echo ""
+ echo "获取 token 的方法:"
+ echo ""
+ echo " 方法 1 - 使用 gh CLI:"
+ echo " gh api -X POST repos/$REPO/actions/runners/registration-token --jq '.token'"
+ echo ""
+ echo " 方法 2 - GitHub 网页:"
+ echo " https://github.com/$REPO/settings/actions/runners/new"
+ echo " 复制页面上显示的 token"
+ echo ""
+ exit 1
+fi
+
+# 检测系统架构
+ARCH=$(uname -m)
+OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+
+case "$ARCH" in
+ arm64|aarch64)
+ RUNNER_ARCH="arm64"
+ ;;
+ x86_64)
+ RUNNER_ARCH="x64"
+ ;;
+ *)
+ echo "不支持的架构: $ARCH"
+ exit 1
+ ;;
+esac
+
+case "$OS" in
+ darwin)
+ RUNNER_OS="osx"
+ ;;
+ linux)
+ RUNNER_OS="linux"
+ ;;
+ *)
+ echo "不支持的操作系统: $OS"
+ exit 1
+ ;;
+esac
+
+RUNNER_TAR="actions-runner-${RUNNER_OS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"
+RUNNER_URL="https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${RUNNER_TAR}"
+
+echo "Runner 包: $RUNNER_TAR"
+echo ""
+
+# 创建基础目录
+mkdir -p "$RUNNER_BASE_DIR"
+cd "$RUNNER_BASE_DIR"
+
+# 下载 runner
+if [ ! -f "$RUNNER_TAR" ]; then
+ echo "下载 GitHub Actions Runner v${RUNNER_VERSION}..."
+ curl -o "$RUNNER_TAR" -L "$RUNNER_URL"
+ echo ""
+fi
+
+# 配置每个 runner
+for i in $(seq 1 $NUM_RUNNERS); do
+ RUNNER_DIR="$RUNNER_BASE_DIR/runner-$i"
+ RUNNER_NAME="$(hostname)-runner-$i"
+
+ echo "=========================================="
+ echo " 配置 Runner $i: $RUNNER_NAME"
+ echo "=========================================="
+
+ # 如果已存在且已配置,跳过
+ if [ -d "$RUNNER_DIR" ] && [ -f "$RUNNER_DIR/.runner" ]; then
+ echo "Runner $i 已配置,跳过"
+ continue
+ fi
+
+ # 创建目录并解压
+ mkdir -p "$RUNNER_DIR"
+ tar xzf "$RUNNER_TAR" -C "$RUNNER_DIR"
+
+ # 配置 runner
+ cd "$RUNNER_DIR"
+ ./config.sh \
+ --url "https://github.com/$REPO" \
+ --token "$GITHUB_TOKEN" \
+ --name "$RUNNER_NAME" \
+ --labels "self-hosted,${RUNNER_OS},${RUNNER_ARCH}" \
+ --work "_work" \
+ --unattended \
+ --replace
+
+ cd "$RUNNER_BASE_DIR"
+ echo ""
+done
+
+# 生成管理脚本
+echo "生成管理脚本..."
+
+# start-all.sh
+cat > "$RUNNER_BASE_DIR/start-all.sh" << 'SCRIPT'
+#!/bin/bash
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "启动所有 runners..."
+for dir in "$SCRIPT_DIR"/runner-*; do
+ if [ -d "$dir" ] && [ -f "$dir/run.sh" ]; then
+ NAME=$(basename "$dir")
+ if pgrep -f "$dir/bin/Runner.Listener" > /dev/null; then
+ echo " $NAME: 已在运行"
+ else
+ echo " $NAME: 启动中..."
+ cd "$dir"
+ nohup ./run.sh > runner.log 2>&1 &
+ echo " $NAME: PID $!"
+ fi
+ fi
+done
+echo ""
+echo "查看日志: tail -f $SCRIPT_DIR/runner-*/runner.log"
+SCRIPT
+chmod +x "$RUNNER_BASE_DIR/start-all.sh"
+
+# stop-all.sh
+cat > "$RUNNER_BASE_DIR/stop-all.sh" << 'SCRIPT'
+#!/bin/bash
+echo "停止所有 runners..."
+pkill -f "Runner.Listener" 2>/dev/null || true
+pkill -f "Runner.Worker" 2>/dev/null || true
+echo "完成"
+SCRIPT
+chmod +x "$RUNNER_BASE_DIR/stop-all.sh"
+
+# status.sh
+cat > "$RUNNER_BASE_DIR/status.sh" << 'SCRIPT'
+#!/bin/bash
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "Runner 状态:"
+echo ""
+for dir in "$SCRIPT_DIR"/runner-*; do
+ if [ -d "$dir" ]; then
+ NAME=$(basename "$dir")
+ if pgrep -f "$dir/bin/Runner.Listener" > /dev/null; then
+ PID=$(pgrep -f "$dir/bin/Runner.Listener")
+ echo " ✓ $NAME: 运行中 (PID: $PID)"
+ else
+ echo " ✗ $NAME: 已停止"
+ fi
+ fi
+done
+SCRIPT
+chmod +x "$RUNNER_BASE_DIR/status.sh"
+
+# restart-all.sh
+cat > "$RUNNER_BASE_DIR/restart-all.sh" << 'SCRIPT'
+#!/bin/bash
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+"$SCRIPT_DIR/stop-all.sh"
+sleep 2
+"$SCRIPT_DIR/start-all.sh"
+SCRIPT
+chmod +x "$RUNNER_BASE_DIR/restart-all.sh"
+
+echo ""
+echo "=========================================="
+echo " 配置完成!"
+echo "=========================================="
+echo ""
+echo "管理脚本:"
+echo " $RUNNER_BASE_DIR/start-all.sh - 启动所有 runners"
+echo " $RUNNER_BASE_DIR/stop-all.sh - 停止所有 runners"
+echo " $RUNNER_BASE_DIR/status.sh - 查看状态"
+echo " $RUNNER_BASE_DIR/restart-all.sh - 重启所有 runners"
+echo ""
+echo "现在执行:"
+echo " $RUNNER_BASE_DIR/start-all.sh"
+echo ""