feat(tools): guard against shipping an action that is not published
act_runner never runs this working copy. Every job re-clones the repository from the Gitea server at the ref the workflow names, so a fix under .gitea/ is inert until it is pushed -- and nothing here says so. `git log` shows the fix while CI keeps running the old file. That gap cost a downstream repo a day: 59 commits sat unpushed while they debugged a snapshot-name bug we had already fixed and announced as done. ci-action-published.py compares the working tree against the remote ref, which catches both committed-but-unpushed and edited-but-uncommitted in one diff. It fetches first, so it compares against the server rather than a remote-tracking ref that can itself be stale -- the same failure mode one level down. Exit 1 on drift, 2 when the check could not run.
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+106
@@ -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())
|
||||
Reference in New Issue
Block a user