diff --git a/.gitea/ci/sync_tsl_playbook.sh b/.gitea/ci/sync_tsl_playbook.sh deleted file mode 100644 index fd8ccd63..00000000 --- a/.gitea/ci/sync_tsl_playbook.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build the minimal TSL playbook bundle and publish its *expanded* contents to -# the tsl-playbook branch. The branch tree mirrors the bundle root directly -# (AGENTS.md, docs/, skills/) β€” there is no wrapping tsl-playbook/ directory. -# -# Only the paths produced by the bundle are managed. Any other file that lives -# on the branch (e.g. a hand-written README.md) is preserved across syncs. - -REPO_DIR="${REPO_DIR:-$(pwd)}" -TARGET_BRANCH="${TARGET_BRANCH:-tsl-playbook}" -BUILD_SCRIPT="${BUILD_SCRIPT:-scripts/build_tsl_playbook.py}" -COMMIT_AUTHOR_NAME="${COMMIT_AUTHOR_NAME:-ci[bot]}" -COMMIT_AUTHOR_EMAIL="${COMMIT_AUTHOR_EMAIL:-ci-bot@local}" - -cd "$REPO_DIR" - -git config user.name "$COMMIT_AUTHOR_NAME" -git config user.email "$COMMIT_AUTHOR_EMAIL" - -source_sha="$(git rev-parse HEAD)" -source_short="$(git rev-parse --short HEAD)" - -# Build the bundle into a temp dir *outside* the repo so that resetting the -# target branch's working tree can never clobber the freshly built artifact. -build_dir="$(mktemp -d)" -cleanup() { - rm -rf "$build_dir" -} -trap cleanup EXIT - -bundle="$build_dir/tsl-playbook" -python3 "$BUILD_SCRIPT" --output "$bundle" - -# These are the only paths this workflow owns on the branch. Everything else is -# left alone, including hand-written files such as README.md. -generated_paths=(AGENTS.md docs skills) -for path in "${generated_paths[@]}"; do - if [ ! -e "$bundle/$path" ]; then - echo "ERROR: bundle is missing expected path: $path" >&2 - exit 1 - fi -done - -# Check out (or create) the target branch. -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" -else - # A brand-new orphan branch inherits main's index. Clear the index without - # deleting source files from the working tree; only generated_paths are staged. - git checkout --orphan "$TARGET_BRANCH" - git rm -rf --cached --quiet . >/dev/null 2>&1 || true -fi - -# Remove only generated paths before copying the freshly built bundle. -rm -rf "${generated_paths[@]}" - -# Copy bundle contents (including dotfiles) to the repo root. -cp -R "$bundle"/. "$REPO_DIR"/ - -git add -A "${generated_paths[@]}" - -if git diff --cached --quiet; then - echo "No tsl-playbook changes to publish." - exit 0 -fi - -git commit -m ":package: deps(tsl): sync tsl-playbook from ${source_short} - -Source-Commit: ${source_sha}" - -TOKEN="${WORKFLOW:-}" -if [ -n "$TOKEN" ] && [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then - git remote set-url origin "https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" -fi - -git push origin "$TARGET_BRANCH" -echo "βœ… Published tsl-playbook @ ${source_short}" diff --git a/.gitea/workflows/sync-tsl-playbook.yml b/.gitea/workflows/sync-tsl-playbook.yml index 54e179d3..1f15821a 100644 --- a/.gitea/workflows/sync-tsl-playbook.yml +++ b/.gitea/workflows/sync-tsl-playbook.yml @@ -22,6 +22,8 @@ jobs: steps: - name: πŸ“₯ 准倇仓库 shell: bash + env: + WORKFLOW: ${{ secrets.WORKFLOW }} run: | set -euo pipefail @@ -30,15 +32,28 @@ jobs: echo "========================================" REPO_NAME="${{ github.event.repository.name }}" - TOKEN="${{ secrets.WORKFLOW }}" 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" + export GIT_TERMINAL_PROMPT=0 + echo "GIT_TERMINAL_PROMPT=0" >> "$GITHUB_ENV" + + 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' ;; + esac + EOF + chmod 700 "$ASKPASS_SCRIPT" + export GIT_ASKPASS="$ASKPASS_SCRIPT" + echo "GIT_ASKPASS=$ASKPASS_SCRIPT" >> "$GITHUB_ENV" fi + REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git clone "$REPO_URL" "$REPO_DIR" git -C "$REPO_DIR" fetch origin main @@ -54,15 +69,74 @@ jobs: WORKFLOW: ${{ secrets.WORKFLOW }} run: | set -euo pipefail + + REPO_DIR="${REPO_DIR:-$(pwd)}" + TARGET_BRANCH="${TARGET_BRANCH:-tsl-playbook}" + BUILD_SCRIPT="${BUILD_SCRIPT:-scripts/build_tsl_playbook.py}" + COMMIT_AUTHOR_NAME="${COMMIT_AUTHOR_NAME:-ci[bot]}" + COMMIT_AUTHOR_EMAIL="${COMMIT_AUTHOR_EMAIL:-ci-bot@local}" + cd "$REPO_DIR" echo "========================================" echo "πŸ”¨ Build bundle and sync $TARGET_BRANCH" echo "========================================" - TARGET_BRANCH="$TARGET_BRANCH" bash .gitea/ci/sync_tsl_playbook.sh + git config user.name "$COMMIT_AUTHOR_NAME" + git config user.email "$COMMIT_AUTHOR_EMAIL" + + source_sha="$(git rev-parse HEAD)" + source_short="$(git rev-parse --short HEAD)" + + # Build the bundle outside the repo so branch checkout cannot clobber it. + build_dir="$(mktemp -d)" + cleanup() { + rm -rf "$build_dir" + } + trap cleanup EXIT + + bundle="$build_dir/tsl-playbook" + python3 "$BUILD_SCRIPT" --output "$bundle" + + # These are the only paths this workflow owns on the target branch. + generated_paths=(AGENTS.md docs skills) + for path in "${generated_paths[@]}"; do + if [ ! -e "$bundle/$path" ]; then + echo "ERROR: bundle is missing expected path: $path" >&2 + exit 1 + 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" + 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 + + rm -rf "${generated_paths[@]}" + cp -R "$bundle"/. "$REPO_DIR"/ + + git add -A "${generated_paths[@]}" + + if git diff --cached --quiet; then + echo "No tsl-playbook changes to publish." + exit 0 + fi + + git commit -m ":package: deps(tsl): sync tsl-playbook from ${source_short} + + Source-Commit: ${source_sha}" + + git push origin "$TARGET_BRANCH" + echo "βœ… Published tsl-playbook @ ${source_short}" - name: 🧹 清理临既仓库 if: always() run: | + if [ -n "${GIT_ASKPASS:-}" ]; then + rm -f "$GIT_ASKPASS" + fi rm -rf "$REPO_DIR" diff --git a/test/test_build_tsl_playbook.py b/test/test_build_tsl_playbook.py index a5a86874..0860ee0c 100644 --- a/test/test_build_tsl_playbook.py +++ b/test/test_build_tsl_playbook.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "build_tsl_playbook.py" -SYNC_SCRIPT = ROOT / ".gitea" / "ci" / "sync_tsl_playbook.sh" +SYNC_WORKFLOW = ROOT / ".gitea" / "workflows" / "sync-tsl-playbook.yml" class BuildTslPlaybookTests(unittest.TestCase): @@ -61,16 +61,23 @@ class BuildTslPlaybookTests(unittest.TestCase): output_skill = count_files(output / "skills" / "tsl-api-reference") self.assertEqual(output_skill, source_skill) - def test_sync_script_does_not_remove_entire_target_branch(self): - text = SYNC_SCRIPT.read_text(encoding="utf-8") + def test_sync_workflow_does_not_remove_entire_target_branch(self): + text = SYNC_WORKFLOW.read_text(encoding="utf-8") self.assertNotRegex(text, r"git rm -rf --quiet\s+\.") self.assertIn("generated_paths=(AGENTS.md docs skills)", text) self.assertIn('git add -A "${generated_paths[@]}"', text) + self.assertNotIn(".gitea/ci/", text) + self.assertNotIn("https://oauth2", text) + self.assertNotIn("oauth2:${TOKEN}", text) + self.assertNotRegex(text, r"REPO_URL=.*(TOKEN|WORKFLOW)") + self.assertNotIn("git remote set-url", text) + self.assertIn("GIT_ASKPASS", text) + self.assertIn('REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"', text) def test_sync_preserves_files_outside_generated_paths(self): if shutil.which("bash") is None: - self.skipTest("bash is required to run sync_tsl_playbook.sh") + self.skipTest("bash is required to run sync workflow script") with tempfile.TemporaryDirectory() as tmp_dir: repo = create_source_repo(Path(tmp_dir)) @@ -104,7 +111,7 @@ class BuildTslPlaybookTests(unittest.TestCase): def test_sync_creates_new_branch_without_source_files(self): if shutil.which("bash") is None: - self.skipTest("bash is required to run sync_tsl_playbook.sh") + self.skipTest("bash is required to run sync workflow script") with tempfile.TemporaryDirectory() as tmp_dir: repo = create_source_repo(Path(tmp_dir)) @@ -119,7 +126,7 @@ class BuildTslPlaybookTests(unittest.TestCase): git(repo, "cat-file", "-e", f"HEAD:{path}") for path in ( - ".gitea/ci/sync_tsl_playbook.sh", + ".gitea/workflows/sync-tsl-playbook.yml", "scripts/build_tsl_playbook.py", "rulesets/tsl/index.md", ): @@ -159,22 +166,32 @@ def run_sync(repo: Path) -> None: "COMMIT_AUTHOR_EMAIL": "test@example.invalid", } ) - result = subprocess.run( - ["bash", ".gitea/ci/sync_tsl_playbook.sh"], - cwd=repo, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) + with tempfile.NamedTemporaryFile( + "w", suffix=".sh", encoding="utf-8", newline="\n", delete=False + ) as script_file: + script_file.write(extract_sync_workflow_script()) + script_path = script_file.name + try: + result = subprocess.run( + ["bash", script_path], + cwd=repo, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + finally: + os.unlink(script_path) if result.returncode != 0: raise AssertionError(result.stderr + result.stdout) def copy_required_sources(repo: Path) -> None: - (repo / ".gitea" / "ci").mkdir(parents=True) - shutil.copy2(SYNC_SCRIPT, repo / ".gitea" / "ci" / "sync_tsl_playbook.sh") + (repo / ".gitea" / "workflows").mkdir(parents=True) + shutil.copy2( + SYNC_WORKFLOW, repo / ".gitea" / "workflows" / "sync-tsl-playbook.yml" + ) (repo / "scripts").mkdir() shutil.copy2(SCRIPT, repo / "scripts" / "build_tsl_playbook.py") @@ -203,6 +220,36 @@ def count_files(path: Path) -> int: return sum(1 for item in path.rglob("*") if item.is_file()) +def extract_sync_workflow_script() -> str: + lines = SYNC_WORKFLOW.read_text(encoding="utf-8").splitlines() + in_sync_step = False + in_run_block = False + script_lines: list[str] = [] + + for line in lines: + if line.startswith(" - name: πŸ“¦ Build and publish tsl-playbook"): + in_sync_step = True + continue + if in_sync_step and line.startswith(" - name: "): + break + if in_sync_step and line == " run: |": + in_run_block = True + continue + if not in_run_block: + continue + if line.startswith(" "): + script_lines.append(line[10:]) + continue + if line.strip() == "": + script_lines.append("") + continue + break + + if not script_lines: + raise AssertionError("sync workflow run block was not found") + return "\n".join(script_lines) + "\n" + + def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: return run(["git", *args], cwd=repo)