diff --git a/.gitea/workflows/checks.yml b/.gitea/workflows/checks.yml new file mode 100644 index 00000000..7c46cc69 --- /dev/null +++ b/.gitea/workflows/checks.yml @@ -0,0 +1,157 @@ +name: 🔎 校验 + +on: + workflow_run: + workflows: ["🧰 准备环境"] + types: + - completed + +concurrency: + group: checks-${{ github.repository }}-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true + +# ========================================== +# 🔧 配置区域 - 校验参数 +# ========================================== +env: + # Prepare 完成后经 workflow_run 触发;Prepare 已装工具 + fetch 到共享 bare 仓库。 + # 本 workflow 只做只读校验(lint + 测试),不推任何分支,故 token 仅用于 + # 极端情况下补取缺失提交(正常路径 Prepare 已 fetch 到位)。 + # + # 安全:ACCESS_TOKEN 不放在 workflow 级 env。lint/test 步骤会执行上游提交 + # (PR 场景为不受信输入)的脚本,其环境里不应出现 token;仅下放到 + # prepare_worktree 步骤。 + WORKSPACE_ROOT: "/data/workspace" + SOURCE_BRANCH: ${{ github.event.workflow_run.head_branch }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + SOURCE_EVENT: ${{ github.event.workflow_run.event }} + COMMIT_LINT_REQUIRE_EMOJI: "1" + +jobs: + checks: + # 仅在 Prepare 成功后触发。Prepare 覆盖 push(main) / pull_request / + # schedule / dispatch,故 checks 在这些场景均会运行。 + # lint 与 test 合并为单 job:共用一个临时 worktree,准备一次,顺序执行 + # (lint 在前 fail-fast,失败即不跑 test)。二者跑同一 SOURCE_SHA、皆只读、 + # 无依赖,无需拆分为并行 job(那会重复整段 worktree 准备逻辑 + 多一个 worktree)。 + if: ${{ github.event.workflow_run.conclusion == 'success' }} + name: 🔎 规范检查 + 全量测试 + runs-on: standard-ubuntu-22 + permissions: + contents: read + + steps: + - name: 📥 准备临时 Worktree + id: prepare_worktree + shell: bash + env: + ACCESS_TOKEN: ${{ secrets.WORKFLOW }} + run: | + set -euo pipefail + + REPO_NAME="${{ github.event.repository.name }}" + HEAD_SHA="${SOURCE_SHA}" + REPOSITORY_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/repository.git" + WORKTREE_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/worktrees/checks-${{ github.run_id }}-${{ github.run_attempt }}" + WORKTREE_LOCK="${WORKSPACE_ROOT}/${REPO_NAME}/worktree-admin.lock" + + case "$HEAD_SHA" in + ''|*[!0-9a-fA-F]*) + echo "无效的上游提交 SHA: $HEAD_SHA" >&2 + exit 1 + ;; + esac + if [ ! -d "$REPOSITORY_DIR" ] || \ + [ "$(git --git-dir="$REPOSITORY_DIR" rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then + echo "Prepare 未创建有效共享 bare 仓库: $REPOSITORY_DIR" >&2 + exit 1 + fi + + # 正常路径 Prepare 已把上游提交 fetch 到位;极端情况下(bare 仓库被清理 + # 重建、或 Prepare 与本 run 之间发生 prune)补取一次,token 仅用于此。 + if ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + git \ + -c credential.helper= \ + -c 'credential.helper=!f() { test "$1" = get || exit 0; printf "%s\n" username=oauth2; printf "%s\n" "password=${ACCESS_TOKEN}"; }; f' \ + -c credential.interactive=never \ + --git-dir="$REPOSITORY_DIR" fetch --prune --force origin \ + '+refs/heads/*:refs/remotes/origin/*' || true + fi + if ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}"; then + echo "共享仓库中不存在上游提交: $HEAD_SHA(事件: $SOURCE_EVENT)" >&2 + exit 1 + fi + + exec 9>"$WORKTREE_LOCK" + flock 9 + # 回收陈旧临时 worktree(超过 24h 的 checks-* 目录)。worktree prune 只清理 + # 指向已消失目录的 metadata,不删仍存在的陈旧目录,故按 mtime 主动回收。 + worktrees_root="$(dirname "$WORKTREE_DIR")" + if [ -d "$worktrees_root" ]; then + find "$worktrees_root" -mindepth 1 -maxdepth 1 -type d -name 'checks-*' \ + -mmin +1440 -print 2>/dev/null | while IFS= read -r stale; do + [ "$stale" = "$WORKTREE_DIR" ] && continue + git --git-dir="$REPOSITORY_DIR" worktree remove --force "$stale" 2>/dev/null || rm -rf "$stale" + done + fi + git --git-dir="$REPOSITORY_DIR" worktree prune + if [ -e "$WORKTREE_DIR" ]; then + git --git-dir="$REPOSITORY_DIR" worktree remove --force "$WORKTREE_DIR" 2>/dev/null || rm -rf "$WORKTREE_DIR" + fi + mkdir -p "$(dirname "$WORKTREE_DIR")" + git --git-dir="$REPOSITORY_DIR" worktree add --detach "$WORKTREE_DIR" "$HEAD_SHA" + flock -u 9 + + git -C "$WORKTREE_DIR" reset --hard "$HEAD_SHA" + git -C "$WORKTREE_DIR" clean -ffdx + + echo "REPOSITORY_DIR=$REPOSITORY_DIR" >> "$GITHUB_ENV" + echo "REPO_DIR=$WORKTREE_DIR" >> "$GITHUB_ENV" + echo "✅ Worktree 就绪: $WORKTREE_DIR @ $HEAD_SHA" + + - name: 🔍 校验提交信息 + shell: bash + run: | + set -euo pipefail + cd "$REPO_DIR" + # 注:经 workflow_run 触发,事件 payload 为 workflow_run,不含 PR title; + # lint 脚本会回落到 head commit message 校验(PR 标题本身不再校验)。 + echo "🔍 检查 commit message 规范(上游事件: $SOURCE_EVENT)..." + python3 .gitea/ci/commit_message_lint.py + echo "✅ Lint 检查通过" + + - name: 🧪 运行全量测试 + shell: bash + run: | + set -euo pipefail + + echo "========================================" + echo "🐍 Python 测试(上游事件: $SOURCE_EVENT)" + echo "========================================" + + cd "$REPO_DIR" + echo "📋 覆盖:CLI、subtree/snapshot 部署路线、模板同步、文档一致性" + python3 -m unittest discover -s test -p "test_*.py" -v + echo "✅ Python 测试通过" + + echo "========================================" + echo "🔗 文档链接检查" + echo "========================================" + + python3 test/integration/check_doc_links.py + echo "✅ 文档链接检查通过" + echo "🎉 所有测试完成" + + - name: 🧹 清理临时 Worktree + if: always() + shell: bash + run: | + set -uo pipefail + [ -n "${REPOSITORY_DIR:-}" ] || exit 0 + [ -n "${REPO_DIR:-}" ] || exit 0 + WORKTREE_LOCK="$(dirname "$REPOSITORY_DIR")/worktree-admin.lock" + exec 9>"$WORKTREE_LOCK" + flock 9 + git --git-dir="$REPOSITORY_DIR" worktree remove --force "$REPO_DIR" 2>/dev/null || rm -rf "$REPO_DIR" + git --git-dir="$REPOSITORY_DIR" worktree prune + flock -u 9 diff --git a/.gitea/workflows/prepare.yml b/.gitea/workflows/prepare.yml new file mode 100644 index 00000000..01faa435 --- /dev/null +++ b/.gitea/workflows/prepare.yml @@ -0,0 +1,212 @@ +name: 🧰 准备环境 + +on: + push: + # 主分支:下游 sync / thirdparty 消费者只关心 main。 + # 其它分支(含 ci bot 推送的 tsl-playbook / thirdparty/skill 分支)跑 Prepare 纯属空耗,故在此过滤。 + branches: + - main + pull_request: + # PR:为下游 checks 消费者(standards + tests)预备 bare 仓库。 + # PR 的 github.sha 是 Gitea 生成的 merge commit,不在 refs/heads/*, + # 故下面的 fetch 额外取 PR ref 并按事件类型选校验目标。 + branches: + - main + workflow_dispatch: + schedule: + # thirdparty 快照每日轮询:Prepare 定时成功后,update-thirdparty-skills 通过 workflow_run 触发。 + - cron: "17 3 * * *" + +concurrency: + group: prepare-${{ github.repository }} + cancel-in-progress: false + +env: + ACCESS_TOKEN: ${{ secrets.WORKFLOW }} + WORKSPACE_ROOT: "/data/workspace" + +jobs: + prepare: + runs-on: standard-ubuntu-22 + permissions: + contents: read + + steps: + - name: 🔧 检查并安装共享工具 + id: tools + shell: bash + run: | + set -euo pipefail + + declare -A package_for=( + [git]="git" + [python3]="python3" + [curl]="curl" + [flock]="util-linux" + [realpath]="coreutils" + ) + required_commands=(git python3 curl flock realpath) + missing_commands=() + missing_packages=() + declare -A seen_packages=() + + for command in "${required_commands[@]}"; do + if ! command -v "$command" >/dev/null 2>&1; then + package="${package_for[$command]}" + missing_commands+=("$command") + if [[ -z "${seen_packages[$package]+x}" ]]; then + missing_packages+=("$package") + seen_packages[$package]=1 + fi + fi + done + + installed=false + if [ "${#missing_packages[@]}" -gt 0 ]; then + echo "缺失命令: ${missing_commands[*]}" + echo "准备安装: ${missing_packages[*]}" + + if [ "$(id -u)" -eq 0 ]; then + APT=(apt-get) + elif command -v sudo >/dev/null 2>&1; then + APT=(sudo apt-get) + else + echo "无法安装缺失命令;当前用户不是 root 且没有 sudo" >&2 + echo "命令: ${missing_commands[*]}" >&2 + echo "包: ${missing_packages[*]}" >&2 + exit 1 + fi + + "${APT[@]}" update -qq + "${APT[@]}" install -y -qq "${missing_packages[@]}" + installed=true + fi + + for command in "${required_commands[@]}"; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "安装后仍找不到命令: $command (${package_for[$command]})" >&2 + exit 1 + fi + done + + { + echo "## Prepare 环境" + echo "" + echo "| 命令 | 版本 |" + echo "| --- | --- |" + for command in "${required_commands[@]}"; do + version_output=$("$command" --version 2>&1 || true) + version_line=${version_output%%$'\n'*} + version_line=${version_line//|/\\|} + printf '| `%s` | `%s` |\n' "$command" "$version_line" + done + echo "" + if [ "$installed" = true ]; then + echo "安装的包:\`${missing_packages[*]}\`" + else + echo "安装的包:无,所有命令已存在。" + fi + } >> "$GITHUB_STEP_SUMMARY" + + echo "installed=$installed" >> "$GITHUB_OUTPUT" + + - name: 📥 准备共享 bare 仓库 + shell: bash + run: | + set -euo pipefail + + REPO_NAME="${{ github.event.repository.name }}" + REMOTE_URL="${{ github.server_url }}/${{ github.repository }}.git" + EVENT_NAME="${{ github.event_name }}" + REPOSITORY_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/repository.git" + + # 校验目标 SHA 因事件而异: + # - pull_request:github.sha 是 Gitea 生成的 merge commit,不在 + # refs/heads/* 里,直接校验会失败;改用 PR head SHA,并在下方额外 + # fetch PR ref 把 head 提交拉进 bare 仓库。下游 workflow_run 消费者 + # 读到的 head_sha 也正是这个 PR head,两者一致。 + # - 其它事件(push / schedule / dispatch):沿用 github.sha。 + if [ "$EVENT_NAME" = "pull_request" ]; then + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + PR_NUMBER="${{ github.event.pull_request.number }}" + else + HEAD_SHA="${{ github.sha }}" + PR_NUMBER="" + fi + + case "$HEAD_SHA" in + ''|*[!0-9a-fA-F]*) + echo "无效的触发提交 SHA: $HEAD_SHA" >&2 + exit 1 + ;; + esac + case "$REMOTE_URL" in + https://*) ;; + *) + echo "拒绝通过非 HTTPS remote 使用 WORKFLOW token: $REMOTE_URL" >&2 + exit 1 + ;; + esac + if [ -z "$ACCESS_TOKEN" ]; then + echo "未配置 WORKFLOW secret,无法读取仓库" >&2 + exit 1 + fi + + mkdir -p "$(dirname "$REPOSITORY_DIR")" + if [ ! -e "$REPOSITORY_DIR" ]; then + git init --bare "$REPOSITORY_DIR" + else + if [ ! -d "$REPOSITORY_DIR" ]; then + echo "共享仓库路径存在但不是目录: $REPOSITORY_DIR" >&2 + exit 1 + fi + if ! is_bare=$(git --git-dir="$REPOSITORY_DIR" rev-parse --is-bare-repository 2>/dev/null) || \ + [ "$is_bare" != "true" ]; then + echo "共享仓库路径不是有效 bare 仓库: $REPOSITORY_DIR" >&2 + exit 1 + fi + fi + + if git --git-dir="$REPOSITORY_DIR" remote get-url origin >/dev/null 2>&1; then + git --git-dir="$REPOSITORY_DIR" remote set-url origin "$REMOTE_URL" + else + git --git-dir="$REPOSITORY_DIR" remote add origin "$REMOTE_URL" + fi + + git \ + -c credential.helper= \ + -c 'credential.helper=!f() { test "$1" = get || exit 0; printf "%s\n" username=oauth2; printf "%s\n" "password=${ACCESS_TOKEN}"; }; f' \ + -c credential.interactive=never \ + --git-dir="$REPOSITORY_DIR" fetch \ + --prune --prune-tags --tags --force origin \ + '+refs/heads/*:refs/remotes/origin/*' + + # PR head 不在 refs/heads/*,按需补取 PR ref 把 head 提交拉进 bare 仓库。 + # 先试 PR head SHA 是否已在库(同仓库分支 PR 的 head 常已随 refs/heads/* + # 到位),不在再按 PR 号 fetch Gitea 的 refs/pull//head。 + if [ -n "$PR_NUMBER" ] && \ + ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + git \ + -c credential.helper= \ + -c 'credential.helper=!f() { test "$1" = get || exit 0; printf "%s\n" username=oauth2; printf "%s\n" "password=${ACCESS_TOKEN}"; }; f' \ + -c credential.interactive=never \ + --git-dir="$REPOSITORY_DIR" fetch --force origin \ + "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pull/${PR_NUMBER}/head" || true + fi + + if ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}"; then + echo "共享仓库中不存在触发提交: $HEAD_SHA(事件: $EVENT_NAME)" >&2 + exit 1 + fi + + resolved_sha=$(git --git-dir="$REPOSITORY_DIR" rev-parse "${HEAD_SHA}^{commit}") + { + echo "" + echo "## Prepare 仓库" + echo "" + echo "- bare 仓库:\`$REPOSITORY_DIR\`" + echo "- 触发提交:\`$resolved_sha\`" + echo "- 远端:\`$REMOTE_URL\`" + } >> "$GITHUB_STEP_SUMMARY" + + echo "Prepare 完成: $REPOSITORY_DIR @ $resolved_sha" diff --git a/.gitea/workflows/standards-check.yml b/.gitea/workflows/standards-check.yml deleted file mode 100644 index 8384d773..00000000 --- a/.gitea/workflows/standards-check.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: ✅ Standards Check - -on: - push: - pull_request: - workflow_dispatch: # 允许手动触发 - -concurrency: - group: standards-${{ github.repository }}-${{ github.ref }} - cancel-in-progress: true - -# ========================================== -# 🔧 配置区域 - 标准校验参数 -# ========================================== -env: - COMMIT_LINT_REQUIRE_EMOJI: "1" - WORKSPACE_DIR: "/home/workspace" - -jobs: - commit-message: - name: 🔍 Commit message lint - runs-on: ubuntu-22.04 - - steps: - - name: 📥 准备仓库 - run: | - set -euo pipefail - - echo "========================================" - echo "📦 准备仓库到 WORKSPACE_DIR" - echo "========================================" - - REPO_NAME="${{ github.event.repository.name }}" - TOKEN="${{ secrets.WORKFLOW }}" - TARGET_SHA="${{ github.sha }}" - TARGET_REF="${{ github.ref }}" - TARGET_REF_NAME="${{ github.ref_name }}" - - mkdir -p "$WORKSPACE_DIR" - REPO_DIR="$(mktemp -d "$WORKSPACE_DIR/${REPO_NAME}.XXXXXX")" - - if [ -n "$TOKEN" ]; then - REPO_URL="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" - else - REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - fi - - git clone "$REPO_URL" "$REPO_DIR" - - if git -C "$REPO_DIR" cat-file -e "${TARGET_SHA}^{commit}" 2>/dev/null; then - git -C "$REPO_DIR" checkout -f "$TARGET_SHA" - else - if [ -n "$TARGET_REF" ]; then - git -C "$REPO_DIR" fetch origin "$TARGET_REF" - git -C "$REPO_DIR" checkout -f FETCH_HEAD - else - git -C "$REPO_DIR" checkout -f "$TARGET_REF_NAME" - fi - fi - - git config --global --add safe.directory "$REPO_DIR" - echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV" - echo "✅ 仓库准备完成" - - - name: 🔍 Lint commit message / PR title - run: | - cd "$REPO_DIR" - echo "🔍 检查 commit message 和 PR title 规范..." - python3 .gitea/ci/commit_message_lint.py - echo "✅ Lint 检查通过" - - - name: 🧹 清理临时仓库 - if: always() - run: | - rm -rf "$REPO_DIR" diff --git a/.gitea/workflows/sync-tsl-playbook.yml b/.gitea/workflows/sync-tsl-playbook.yml index ccb8f226..9702d6ec 100644 --- a/.gitea/workflows/sync-tsl-playbook.yml +++ b/.gitea/workflows/sync-tsl-playbook.yml @@ -1,78 +1,130 @@ -name: 📦 Sync TSL Playbook +name: 📦 发布 TSL Playbook on: - push: - branches: - - main - workflow_dispatch: + workflow_run: + workflows: ["🧰 准备环境"] + types: + - completed concurrency: group: sync-tsl-playbook-${{ github.repository }} - cancel-in-progress: true + cancel-in-progress: false +# ========================================== +# 🔧 配置区域 +# ========================================== env: - WORKSPACE_DIR: "/home/workspace" + # Prepare 维护共享仓库并读取代码;此处 token 仅用于推送。 + ACCESS_TOKEN: ${{ secrets.WORKFLOW }} + + # ===== 工作区配置 ===== + WORKSPACE_ROOT: "/data/workspace" + WORKSPACE_SLOT: "tsl-playbook" + SOURCE_BRANCH: ${{ github.event.workflow_run.head_branch }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + + # ===== 分支与构建配置 ===== TARGET_BRANCH: "tsl-playbook" + BUILD_SCRIPT: "scripts/build_tsl_playbook.py" + + # ===== Git 配置 ===== GIT_USER_NAME: "ci[bot]" GIT_USER_EMAIL: "ci[bot]@tinysoft.com.cn" jobs: sync: - name: 📦 Build and publish tsl-playbook - runs-on: ubuntu-22.04 + # 仅在 Prepare 于主分支成功后触发。 + if: ${{ github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') }} + name: 🔨 构建并发布 tsl-playbook + runs-on: standard-ubuntu-22 + permissions: + contents: write steps: - - name: 📥 准备仓库 + - name: 🔐 验证 Token 配置 shell: bash - env: - WORKFLOW: ${{ secrets.WORKFLOW }} run: | set -euo pipefail + if [ -z "$ACCESS_TOKEN" ]; then + echo "❌ 未配置 WORKFLOW secret,无法推送 $TARGET_BRANCH" >&2 + exit 1 + fi + echo "✅ Token 已配置" + echo "🌿 上游分支: ${{ env.SOURCE_BRANCH }}" + echo "📝 上游提交: ${{ env.SOURCE_SHA }}" - echo "========================================" - echo "📦 准备仓库到 WORKSPACE_DIR" - echo "========================================" + - name: 📥 对齐 Worktree + id: prepare_worktree + shell: bash + run: | + set -euo pipefail REPO_NAME="${{ github.event.repository.name }}" - mkdir -p "$WORKSPACE_DIR" - REPO_DIR="$(mktemp -d "$WORKSPACE_DIR/${REPO_NAME}.XXXXXX")" - export GIT_TERMINAL_PROMPT=0 - echo "GIT_TERMINAL_PROMPT=0" >> "$GITHUB_ENV" + HEAD_SHA="${{ env.SOURCE_SHA }}" + REPOSITORY_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/repository.git" + WORKTREE_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/worktrees/${WORKSPACE_SLOT}" + WORKTREE_LOCK="${WORKSPACE_ROOT}/${REPO_NAME}/worktree-admin.lock" - if [ -n "${WORKFLOW:-}" ]; then - ASKPASS_SCRIPT="$(mktemp "$WORKSPACE_DIR/git-askpass.XXXXXX")" - cat > "$ASKPASS_SCRIPT" <<'EOF' - #!/usr/bin/env sh - case "$1" in - *Username*) printf '%s\n' oauth2 ;; - *Password*) printf '%s\n' "$WORKFLOW" ;; - *) printf '\n' ;; + case "$HEAD_SHA" in + ''|*[!0-9a-fA-F]*) + echo "无效的上游提交 SHA: $HEAD_SHA" >&2 + exit 1 + ;; esac - EOF - chmod 700 "$ASKPASS_SCRIPT" - export GIT_ASKPASS="$ASKPASS_SCRIPT" - echo "GIT_ASKPASS=$ASKPASS_SCRIPT" >> "$GITHUB_ENV" + if [ ! -d "$REPOSITORY_DIR" ] || \ + [ "$(git --git-dir="$REPOSITORY_DIR" rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then + echo "Prepare 未创建有效共享 bare 仓库: $REPOSITORY_DIR" >&2 + exit 1 + fi + if ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}"; then + echo "共享仓库中不存在上游提交: $HEAD_SHA" >&2 + exit 1 fi - REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + if [ ! -f "$WORKTREE_DIR/.git" ]; then + exec 9>"$WORKTREE_LOCK" + flock 9 - git clone "$REPO_URL" "$REPO_DIR" + if [ ! -f "$WORKTREE_DIR/.git" ]; then + if [ -e "$WORKTREE_DIR" ]; then + echo "发布 Worktree 路径已存在但不是 linked worktree: $WORKTREE_DIR" >&2 + exit 1 + fi + mkdir -p "$(dirname "$WORKTREE_DIR")" + git --git-dir="$REPOSITORY_DIR" worktree add --detach "$WORKTREE_DIR" "$HEAD_SHA" + fi - git -C "$REPO_DIR" fetch origin main - git -C "$REPO_DIR" checkout -B main origin/main + flock -u 9 + fi - git config --global --add safe.directory "$REPO_DIR" - echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV" - echo "✅ 仓库准备完成" + common_dir=$(git -C "$WORKTREE_DIR" rev-parse --path-format=absolute --git-common-dir) + if [ "$common_dir" != "$(realpath "$REPOSITORY_DIR")" ]; then + echo "发布 Worktree 不属于共享仓库: $WORKTREE_DIR" >&2 + exit 1 + fi - - name: 📦 Build and publish tsl-playbook + git -C "$WORKTREE_DIR" checkout --detach --force "$HEAD_SHA" + git -C "$WORKTREE_DIR" reset --hard "$HEAD_SHA" + git -C "$WORKTREE_DIR" clean -ffdx + + actual_sha=$(git -C "$WORKTREE_DIR" rev-parse HEAD) + if [ "$actual_sha" != "$HEAD_SHA" ]; then + echo "发布 Worktree 提交不匹配: 期望 $HEAD_SHA,实际 $actual_sha" >&2 + exit 1 + fi + + echo "REPOSITORY_DIR=$REPOSITORY_DIR" >> "$GITHUB_ENV" + echo "REPO_DIR=$WORKTREE_DIR" >> "$GITHUB_ENV" + echo "REPO_NAME=$REPO_NAME" >> "$GITHUB_ENV" + echo "发布 Worktree: $WORKTREE_DIR @ $actual_sha" + + - name: 📦 构建并发布 tsl-playbook shell: bash - env: - WORKFLOW: ${{ secrets.WORKFLOW }} run: | set -euo pipefail - REPO_DIR="${REPO_DIR:-$(pwd)}" + REPO_DIR="${REPO_DIR}" + REPOSITORY_DIR="${REPOSITORY_DIR}" TARGET_BRANCH="${TARGET_BRANCH:-tsl-playbook}" BUILD_SCRIPT="${BUILD_SCRIPT:-scripts/build_tsl_playbook.py}" @@ -85,10 +137,15 @@ jobs: git config user.name "$GIT_USER_NAME" git config user.email "$GIT_USER_EMAIL" - source_sha="$(git rev-parse HEAD)" + # 校验仍在上游提交,构建的是正确源码。 + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "${{ env.SOURCE_SHA }}" ]; then + echo "源码 Worktree 不在上游提交: 期望 ${{ env.SOURCE_SHA }},实际 $actual_sha" >&2 + exit 1 + fi source_short="$(git rev-parse --short HEAD)" - # Build the bundle outside the repo so branch checkout cannot clobber it. + # 在仓库外构建 bundle,切换分支时不会被清理。 build_dir="$(mktemp -d)" cleanup() { rm -rf "$build_dir" @@ -98,7 +155,7 @@ jobs: bundle="$build_dir/tsl-playbook" python3 "$BUILD_SCRIPT" --output "$bundle" - # These are the only paths this workflow owns on the target branch. + # 本 workflow 在目标分支上仅拥有以下路径。 managed_paths=( "AGENTS.md" "docs/tsl" @@ -113,11 +170,11 @@ jobs: fi done - if git show-ref --verify --quiet "refs/remotes/origin/$TARGET_BRANCH"; then - git fetch origin "$TARGET_BRANCH" - git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH" + # 目标分支的引用由 Prepare fetch 到共享仓库;此处无需再 fetch。 + if git --git-dir="$REPOSITORY_DIR" show-ref --verify --quiet "refs/remotes/origin/$TARGET_BRANCH"; then + git checkout --force -B "$TARGET_BRANCH" "refs/remotes/origin/$TARGET_BRANCH" else - # A new orphan branch inherits main's index; clear it without deleting files. + # 目标分支尚不存在:创建孤儿分支,清空索引但保留工作树文件。 git checkout --orphan "$TARGET_BRANCH" git rm -rf --cached --quiet . >/dev/null 2>&1 || true fi @@ -137,15 +194,20 @@ jobs: git commit -m ":package: deps(tsl): sync tsl-playbook from ${source_short} - Source-Commit: ${source_sha}" + Source-Commit: ${{ env.SOURCE_SHA }}" - git push origin "$TARGET_BRANCH" + git -c credential.helper= \ + -c 'credential.helper=!f() { test "$1" = get || exit 0; printf "%s\n" username=oauth2; printf "%s\n" "password=${ACCESS_TOKEN}"; }; f' \ + -c credential.interactive=never \ + push origin "$TARGET_BRANCH" echo "✅ Published tsl-playbook @ ${source_short}" - - name: 🧹 清理临时仓库 + - name: 🧹 重置 Worktree if: always() + shell: bash run: | - if [ -n "${GIT_ASKPASS:-}" ]; then - rm -f "$GIT_ASKPASS" + # 持久化 worktree 由下次运行前的 detached checkout + reset + clean 复用, + # 此处仅在结束时 detach,避免留下已检出的分支阻塞其它 worktree。 + if [ -n "${REPO_DIR:-}" ] && [ -f "$REPO_DIR/.git" ]; then + git -C "$REPO_DIR" checkout --detach --force "${SOURCE_SHA}" 2>/dev/null || true fi - rm -rf "$REPO_DIR" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml deleted file mode 100644 index 8c739a9d..00000000 --- a/.gitea/workflows/test.yml +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: 🧪 Playbook 测试套件 - -"on": - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: # 允许手动触发 - -concurrency: - group: test-${{ github.repository }}-${{ github.ref }} - cancel-in-progress: true - -# ========================================== -# 🔧 配置区域 - 测试参数 -# ========================================== -env: - # 测试工作目录 - WORKSPACE_DIR: "/home/workspace" - -jobs: - # ========================================== - # Job: 全量测试 - # ========================================== - test: - name: 🧪 全量测试 - runs-on: ubuntu-22.04 - - steps: - - name: 📥 准备仓库 - run: | - set -euo pipefail - - echo "========================================" - echo "📦 准备仓库到 WORKSPACE_DIR" - echo "========================================" - - REPO_NAME="${{ github.event.repository.name }}" - TOKEN="${{ secrets.WORKFLOW }}" - TARGET_SHA="${{ github.sha }}" - TARGET_REF="${{ github.ref }}" - TARGET_REF_NAME="${{ github.ref_name }}" - - mkdir -p "$WORKSPACE_DIR" - REPO_DIR="$(mktemp -d "$WORKSPACE_DIR/${REPO_NAME}.XXXXXX")" - - if [ -n "$TOKEN" ]; then - REPO_URL="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" - else - REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - fi - - git clone "$REPO_URL" "$REPO_DIR" - - if git -C "$REPO_DIR" cat-file -e \ - "${TARGET_SHA}^{commit}" 2>/dev/null; then - git -C "$REPO_DIR" checkout -f "$TARGET_SHA" - else - if [ -n "$TARGET_REF" ]; then - git -C "$REPO_DIR" fetch origin "$TARGET_REF" - git -C "$REPO_DIR" checkout -f FETCH_HEAD - else - git -C "$REPO_DIR" checkout -f "$TARGET_REF_NAME" - fi - fi - - git config --global --add safe.directory "$REPO_DIR" - echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV" - echo "✅ 仓库准备完成" - - - name: 🧪 运行全量测试 - shell: bash - run: | - set -euo pipefail - - echo "========================================" - echo "🐍 Python 测试" - echo "========================================" - - cd "$REPO_DIR" - echo "📋 覆盖:CLI、subtree/snapshot 部署路线、模板同步、文档一致性" - python3 -m unittest discover -s test -p "test_*.py" -v - echo "✅ Python 测试通过" - - echo "========================================" - echo "🔗 文档链接检查" - echo "========================================" - - python3 test/integration/check_doc_links.py - echo "✅ 文档链接检查通过" - echo "🎉 所有测试完成" - - - name: 🧹 清理临时仓库 - if: always() - run: | - rm -rf "$REPO_DIR" diff --git a/.gitea/workflows/update-thirdparty-skills.yml b/.gitea/workflows/update-thirdparty-skills.yml index 56ceaf21..a39fc22e 100644 --- a/.gitea/workflows/update-thirdparty-skills.yml +++ b/.gitea/workflows/update-thirdparty-skills.yml @@ -1,19 +1,22 @@ -name: 🪄 Update Third-Party Skills +name: ♻️ 更新第三方 Skills on: - push: - branches: - - main - workflow_dispatch: - schedule: - - cron: "@daily" + workflow_run: + workflows: ["🧰 准备环境"] + types: + - completed concurrency: group: update-thirdparty-${{ github.repository }} - cancel-in-progress: true + cancel-in-progress: false env: - WORKSPACE_DIR: "/home/workspace" + # Prepare 维护共享仓库并读取代码;此处 token 用于 worktree 内的 fetch/push。 + ACCESS_TOKEN: ${{ secrets.WORKFLOW }} + WORKSPACE_ROOT: "/data/workspace" + WORKSPACE_SLOT: "thirdparty" + SOURCE_BRANCH: ${{ github.event.workflow_run.head_branch }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} THIRDPARTY_BRANCH: "thirdparty/skill" MANIFEST_PATH: ".gitea/ci/thirdparty_skills.json" GIT_USER_NAME: "ci[bot]" @@ -21,39 +24,107 @@ env: jobs: update_and_sync: - name: ♻️ Update thirdparty and sync main - runs-on: ubuntu-22.04 + # 仅在 Prepare 于主分支成功后触发(含每日 schedule 触发的 Prepare)。 + if: ${{ github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') }} + name: 📥 更新快照并同步 main + runs-on: standard-ubuntu-22 + permissions: + contents: write steps: - - name: 🧰 Prepare repo + - name: 🔐 验证 Token 配置 + shell: bash + run: | + set -euo pipefail + if [ -z "$ACCESS_TOKEN" ]; then + echo "❌ 未配置 WORKFLOW secret,无法 fetch/push" >&2 + exit 1 + fi + echo "✅ Token 已配置" + echo "🌿 上游分支: ${{ env.SOURCE_BRANCH }}" + echo "📝 上游提交: ${{ env.SOURCE_SHA }}" + + - name: 📥 对齐 Worktree + id: prepare_worktree shell: bash run: | set -euo pipefail - echo "========================================" - echo "📦 Prepare repo in WORKSPACE_DIR" - echo "========================================" - REPO_NAME="${{ github.event.repository.name }}" - TOKEN="${{ secrets.WORKFLOW }}" - mkdir -p "${{ env.WORKSPACE_DIR }}" - REPO_DIR="$(mktemp -d "${{ env.WORKSPACE_DIR }}/${REPO_NAME}.XXXXXX")" - if [ -n "$TOKEN" ]; then - REPO_URL="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${{ github.repository }}.git" - else - REPO_URL="${GITHUB_SERVER_URL}/${{ github.repository }}.git" + HEAD_SHA="${{ env.SOURCE_SHA }}" + REPOSITORY_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/repository.git" + WORKTREE_DIR="${WORKSPACE_ROOT}/${REPO_NAME}/worktrees/${WORKSPACE_SLOT}" + WORKTREE_LOCK="${WORKSPACE_ROOT}/${REPO_NAME}/worktree-admin.lock" + + case "$HEAD_SHA" in + ''|*[!0-9a-fA-F]*) + echo "无效的上游提交 SHA: $HEAD_SHA" >&2 + exit 1 + ;; + esac + if [ ! -d "$REPOSITORY_DIR" ] || \ + [ "$(git --git-dir="$REPOSITORY_DIR" rev-parse --is-bare-repository 2>/dev/null)" != "true" ]; then + echo "Prepare 未创建有效共享 bare 仓库: $REPOSITORY_DIR" >&2 + exit 1 + fi + if ! git --git-dir="$REPOSITORY_DIR" cat-file -e "${HEAD_SHA}^{commit}"; then + echo "共享仓库中不存在上游提交: $HEAD_SHA" >&2 + exit 1 fi - git clone "$REPO_URL" "$REPO_DIR" + # 在共享仓库上配置读取 ACCESS_TOKEN 的凭证助手,供 worktree 内后续 + # fetch/push 透明鉴权。token 不落盘,助手在 git 调用时从环境读取。 + # 幂等:同一 runner 复用共享仓库时,credential.helper 已是多值(空值 + + # helper),单值写入会报 "cannot overwrite multiple values";故先 unset + # 再用两个 --add 重建(空值清空继承链,再追加读环境的助手)。 + git --git-dir="$REPOSITORY_DIR" config credential.interactive never + git --git-dir="$REPOSITORY_DIR" config --unset-all credential.helper 2>/dev/null || true + git --git-dir="$REPOSITORY_DIR" config --add credential.helper '' + git --git-dir="$REPOSITORY_DIR" config --add credential.helper '!f() { test "$1" = get || exit 0; printf "%s\n" username=oauth2; printf "%s\n" "password=${ACCESS_TOKEN}"; }; f' + # 固化 origin 到无 token 的 HTTPS URL:区域脚本内保留的历史 + # `git remote set-url origin https://oauth2:$TOKEN@...` 仅在设置了 + # WORKFLOW env 时才触发;本 job 严禁设置 WORKFLOW,否则 token 会落盘到 + # 共享 bare 仓库的 config 并污染所有其它 slot。此处显式重置以固化约束。 + git --git-dir="$REPOSITORY_DIR" remote set-url origin "${{ github.server_url }}/${{ github.repository }}.git" - git -C "$REPO_DIR" fetch origin main - git -C "$REPO_DIR" checkout -B main origin/main + if [ ! -f "$WORKTREE_DIR/.git" ]; then + exec 9>"$WORKTREE_LOCK" + flock 9 - git config --global --add safe.directory "$REPO_DIR" - echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV" - echo "✅ Repo prepared" + if [ ! -f "$WORKTREE_DIR/.git" ]; then + if [ -e "$WORKTREE_DIR" ]; then + echo "Worktree 路径已存在但不是 linked worktree: $WORKTREE_DIR" >&2 + exit 1 + fi + mkdir -p "$(dirname "$WORKTREE_DIR")" + git --git-dir="$REPOSITORY_DIR" worktree add --detach "$WORKTREE_DIR" "$HEAD_SHA" + fi - - name: ♻️ Update thirdparty and sync main + flock -u 9 + fi + + common_dir=$(git -C "$WORKTREE_DIR" rev-parse --path-format=absolute --git-common-dir) + if [ "$common_dir" != "$(realpath "$REPOSITORY_DIR")" ]; then + echo "Worktree 不属于共享仓库: $WORKTREE_DIR" >&2 + exit 1 + fi + + git -C "$WORKTREE_DIR" checkout --detach --force "$HEAD_SHA" + git -C "$WORKTREE_DIR" reset --hard "$HEAD_SHA" + git -C "$WORKTREE_DIR" clean -ffdx + + actual_sha=$(git -C "$WORKTREE_DIR" rev-parse HEAD) + if [ "$actual_sha" != "$HEAD_SHA" ]; then + echo "Worktree 提交不匹配: 期望 $HEAD_SHA,实际 $actual_sha" >&2 + exit 1 + fi + + echo "REPOSITORY_DIR=$REPOSITORY_DIR" >> "$GITHUB_ENV" + echo "REPO_DIR=$WORKTREE_DIR" >> "$GITHUB_ENV" + echo "REPO_NAME=$REPO_NAME" >> "$GITHUB_ENV" + echo "Worktree: $WORKTREE_DIR @ $actual_sha" + + - name: 📥 更新快照并同步 main shell: bash run: | set -euo pipefail @@ -562,7 +633,12 @@ jobs: echo "✅ Update and sync finished." - - name: 🧹 Clean temporary repo + - name: 🧹 重置 Worktree if: always() + shell: bash run: | - rm -rf "$REPO_DIR" + # 持久化 worktree 由下次运行前的 detached checkout + reset + clean 复用, + # 此处仅在结束时 detach,避免留下已检出的分支阻塞其它 worktree。 + if [ -n "${REPO_DIR:-}" ] && [ -f "$REPO_DIR/.git" ]; then + git -C "$REPO_DIR" checkout --detach --force "${SOURCE_SHA}" 2>/dev/null || true + fi diff --git a/test/README.md b/test/README.md index 8cdee7ec..c8a1ebeb 100644 --- a/test/README.md +++ b/test/README.md @@ -31,13 +31,13 @@ python test/integration/check_doc_links.py ## 🧭 CI 自动化测试 -测试套件通过 Gitea Actions 自动运行(见 `.gitea/workflows/test.yml`): +测试套件通过 Gitea Actions 自动运行(见 `.gitea/workflows/checks.yml` 的 `tests` job): -- **触发时机**: - - 推送到 `main` 分支 - - Pull Request 到 `main` 分支 - - 手动触发(workflow_dispatch) -- **运行平台**:ubuntu-22.04 +- **触发方式**:`workflow_run`——「🧰 准备环境」(`prepare.yml`)完成后触发。 + Prepare 监听 `push:main`、`pull_request`、`workflow_dispatch` 与每日 `schedule`, + 故推送到 main、对 main 开 PR 或手动运行 Prepare 都会经 Prepare 传导到测试。 +- **运行平台**:standard-ubuntu-22 +- **注**:`checks.yml` 同时包含 commit message 规范校验(`standards` job)。 ## 📚 测试详解