#!/opt/ci/venv/bin/python """Check that the composite action running in CI is the one in this checkout. act_runner never executes the working copy on this host. For every job it re-clones the repository *from the Gitea server* at the ref the workflow names (`...@main`) and runs that copy. So a fix to `.gitea/` is inert until it is pushed, and "committed" does not mean "in production" — a distinction that stays invisible until a consumer hits the old behaviour. That is not hypothetical: 59 commits sat unpushed for a day while a downstream repo debugged a snapshot-name bug we had already fixed locally (thread entry furyrs#14, 2026-07-22). Run it as the user who commits, not as ``ci-runner``: it fetches, which needs write access to ``.git``, and the question it answers is "does what I have match what is published". Usage: /opt/ci/venv/bin/python tools/ci-action-published.py ... --ref main --no-fetch # skip the network round-trip Exit 0 when the published `.gitea/` tree matches this working tree, 1 when it drifts, 2 when the check itself could not run. """ from __future__ import annotations import argparse import subprocess import sys from pathlib import Path # Everything under this prefix executes from the server's copy, never from # disk: the composite action consumers call, and our own workflows. WATCHED = ".gitea/" def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False, ) def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--repo", default=".", help="repository to check") ap.add_argument("--remote", default="origin") ap.add_argument("--ref", default="main", help="ref the workflows point at") ap.add_argument( "--no-fetch", action="store_true", help="trust the local remote-tracking ref instead of asking the server", ) args = ap.parse_args() repo = Path(args.repo).resolve() if _git(repo, "rev-parse", "--git-dir").returncode != 0: print(f"not a git repository: {repo}", file=sys.stderr) return 2 if not args.no_fetch: # Without this the comparison is against whatever the last fetch saw, # which can itself be stale — the exact failure mode being guarded. fetched = _git(repo, "fetch", "--quiet", args.remote, args.ref) if fetched.returncode != 0: print( f"cannot reach {args.remote}: {fetched.stderr.strip()}\n" "Re-run with --no-fetch to compare against the last known state.", file=sys.stderr, ) return 2 published = f"{args.remote}/{args.ref}" if _git(repo, "rev-parse", "--verify", "--quiet", published).returncode != 0: print(f"no such ref: {published}", file=sys.stderr) return 2 # One diff covers both ways of being unpublished: committed-but-unpushed, # and edited-but-uncommitted. Both mean CI runs something else. diff = _git(repo, "diff", "--name-only", published, "--", WATCHED) if diff.returncode != 0: print(f"git diff failed: {diff.stderr.strip()}", file=sys.stderr) return 2 drifted = [line for line in diff.stdout.splitlines() if line.strip()] if not drifted: print(f"OK — {WATCHED} matches {published}; CI runs what you see here.") return 0 ahead = _git(repo, "rev-list", "--count", f"{published}..HEAD").stdout.strip() print( f"FAIL — {len(drifted)} file(s) under {WATCHED} differ from {published}.\n" "CI does NOT run these changes: act_runner re-clones the action from " f"the server at {args.ref} for every job.\n", file=sys.stderr, ) for path in drifted: print(f" {path}", file=sys.stderr) if ahead and ahead != "0": print(f"\n{ahead} commit(s) not on {published}. Publish with:", file=sys.stderr) print(f" git push {args.remote} {args.ref}", file=sys.stderr) else: print("\nUncommitted edits — commit them, then push.", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())