diff --git a/.gitea/ci/sync_tsl_playbook.sh b/.gitea/ci/sync_tsl_playbook.sh index 4e5286e0..fd8ccd63 100644 --- a/.gitea/ci/sync_tsl_playbook.sh +++ b/.gitea/ci/sync_tsl_playbook.sh @@ -4,6 +4,9 @@ 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}" @@ -30,23 +33,34 @@ trap cleanup EXIT bundle="$build_dir/tsl-playbook" python3 "$BUILD_SCRIPT" --output "$bundle" -# Check out (or create) the target branch. It is a content-only branch that -# shares no history with main, so an orphan branch keeps it clean. +# 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 -# Replace the entire tracked tree with the bundle contents. `git rm` clears -# tracked files; then copy the expanded bundle to the repo root. -git rm -rf --quiet . >/dev/null 2>&1 || true +# 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 +git add -A "${generated_paths[@]}" if git diff --cached --quiet; then echo "No tsl-playbook changes to publish." diff --git a/test/test_build_tsl_playbook.py b/test/test_build_tsl_playbook.py index a3944cec..a5a86874 100644 --- a/test/test_build_tsl_playbook.py +++ b/test/test_build_tsl_playbook.py @@ -1,3 +1,5 @@ +import os +import shutil import subprocess import sys import tempfile @@ -7,6 +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" class BuildTslPlaybookTests(unittest.TestCase): @@ -58,10 +61,171 @@ 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") + + 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) + + 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") + + with tempfile.TemporaryDirectory() as tmp_dir: + repo = create_source_repo(Path(tmp_dir)) + + git(repo, "checkout", "--orphan", "tsl-playbook") + git(repo, "rm", "-rf", ".") + (repo / "README.md").write_text( + "manual branch note\n", encoding="utf-8", newline="\n" + ) + git(repo, "add", "README.md") + git(repo, "commit", "-m", "manual target branch note") + git(repo, "push", "-u", "origin", "tsl-playbook") + + git(repo, "checkout", "main") + run_sync(repo) + + readme = run( + ["git", "show", "HEAD:README.md"], + cwd=repo, + check=False, + ) + self.assertEqual(readme.returncode, 0, msg=readme.stderr) + self.assertEqual(readme.stdout, "manual branch note\n") + + for path in ( + "AGENTS.md", + "docs/tsl/index.md", + "skills/tsl-api-reference/SKILL.md", + ): + git(repo, "cat-file", "-e", f"HEAD:{path}") + + 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") + + with tempfile.TemporaryDirectory() as tmp_dir: + repo = create_source_repo(Path(tmp_dir)) + + run_sync(repo) + + for path in ( + "AGENTS.md", + "docs/tsl/index.md", + "skills/tsl-api-reference/SKILL.md", + ): + git(repo, "cat-file", "-e", f"HEAD:{path}") + + for path in ( + ".gitea/ci/sync_tsl_playbook.sh", + "scripts/build_tsl_playbook.py", + "rulesets/tsl/index.md", + ): + result = run( + ["git", "cat-file", "-e", f"HEAD:{path}"], + cwd=repo, + check=False, + ) + self.assertNotEqual(result.returncode, 0, msg=f"{path} leaked") + + +def create_source_repo(tmp: Path) -> Path: + repo = tmp / "repo" + remote = tmp / "remote.git" + + run(["git", "init", "--bare", str(remote)]) + run(["git", "init", str(repo)]) + git(repo, "checkout", "-b", "main") + git(repo, "config", "user.name", "test") + git(repo, "config", "user.email", "test@example.invalid") + + copy_required_sources(repo) + git(repo, "add", ".") + git(repo, "commit", "-m", "initial sources") + git(repo, "remote", "add", "origin", "../remote.git") + git(repo, "push", "-u", "origin", "main") + return repo + + +def run_sync(repo: Path) -> None: + env = os.environ.copy() + env.update( + { + "REPO_DIR": str(repo), + "TARGET_BRANCH": "tsl-playbook", + "COMMIT_AUTHOR_NAME": "test", + "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", + ) + 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 / "scripts").mkdir() + shutil.copy2(SCRIPT, repo / "scripts" / "build_tsl_playbook.py") + + (repo / "docs" / "tsl").mkdir(parents=True) + (repo / "docs" / "tsl" / "index.md").write_text( + "# TSL Index\n", encoding="utf-8", newline="\n" + ) + + skill = repo / "skills" / "tsl-api-reference" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: tsl-api-reference\n---\n", encoding="utf-8", newline="\n" + ) + (skill / "scripts" / "lookup.py").write_text( + "print('lookup')\n", encoding="utf-8", newline="\n" + ) + + (repo / "rulesets" / "tsl").mkdir(parents=True) + (repo / "rulesets" / "tsl" / "index.md").write_text( + "# TSL Agent Instructions\n", encoding="utf-8", newline="\n" + ) + def count_files(path: Path) -> int: return sum(1 for item in path.rglob("*") if item.is_file()) +def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return run(["git", *args], cwd=repo) + + +def run( + args: list[str], + cwd: Path | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if check and result.returncode != 0: + raise AssertionError( + f"command failed: {' '.join(args)}\n{result.stderr}{result.stdout}" + ) + return result + + if __name__ == "__main__": unittest.main()