diff --git a/docs/RUNBOOK-PhaseC.md b/docs/RUNBOOK-PhaseC.md index 0f154ca..def70c3 100644 --- a/docs/RUNBOOK-PhaseC.md +++ b/docs/RUNBOOK-PhaseC.md @@ -86,6 +86,7 @@ sudo); `--uninstall` removes the block. The script defines two helpers: | `ci template prepare-win` | Provision a Windows template over WinRM | [§5](#5-credentials--template-provisioning) | | `ci template backup` | Timestamped copy of the template directories | [§5](#5-credentials--template-provisioning) | | `ci retention run` | Purge old artifacts and logs | [§6](#6-maintenance) | +| `tools/ci-action-published.py` | Is the `.gitea/` CI runs the one in this checkout? | [§6](#6-maintenance) | | `ci bench measure ...` | Phase-timing benchmark | [§7](#7-phase-c-testing) | | `ci bench run ...` | Concurrency burn-in | [§7](#7-phase-c-testing) | | `ci smoke run ...` | One end-to-end job with assertions | [§7](#7-phase-c-testing) | @@ -607,6 +608,28 @@ ci retention run --retention-days 30 --aggressive-retention-days 7 --min-free-gb See [§4.6](#46-sweep-orphans) (`ci vm cleanup`). +### Publishing `.gitea/` — committed is not deployed + +act_runner never executes this working copy. For every job it re-clones the +repository **from the Gitea server** at the ref the workflow names +(`...local-ci-build@main`) and runs that copy. Anything under `.gitea/` is +therefore inert until it is pushed, and the gap is invisible from here: `git +log` shows the fix, CI runs the old file. + +This has already cost a consumer a day of debugging — 59 commits sat unpushed +while a downstream repo chased a snapshot-name bug that was fixed locally. +Check before announcing any action change as done: + +```bash +sudo -u ci-runner /opt/ci/venv/bin/python tools/ci-action-published.py +# OK — .gitea/ matches origin/main; CI runs what you see here. +# FAIL — lists the drifted files, exit 1 +``` + +It catches both ways of being unpublished, committed-but-unpushed and +edited-but-uncommitted, and fetches first so it compares against the server +rather than a stale remote-tracking ref (`--no-fetch` to skip the network). + --- ## 7. Phase C testing diff --git a/tools/ci-action-published.py b/tools/ci-action-published.py new file mode 100755 index 0000000..7e91a13 --- /dev/null +++ b/tools/ci-action-published.py @@ -0,0 +1,106 @@ +#!/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). + +Usage: + sudo -u ci-runner /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())