Files
stack/tests/dev/test_gen_config_deploy.py
kert 1e2865acbd fix(hooks): run pre-commit pytest without git's repo-locating env
git exports GIT_DIR and GIT_INDEX_FILE to hooks. The pre-commit pytest
step inherited them, and tests/dev/test_gen_config_deploy.py's scratch
repos (git init --bare / clone / commit / push in tmp dirs) therefore
ran against the repository being committed to: on 2026-10-27 a commit
that staged infra files left its branch on nine "base / docs only / mc
config" commits by author `t`, swapped the index for the fixture's
tree, and wrote uploadpack.allowAnySHA1InWant into the .git/config that
every worktree shares. It only stopped there because `git push origin`
found no `origin` remote.

- sem.hooks: the pytest step gets the hook env minus GIT_DIR,
  GIT_INDEX_FILE, GIT_WORK_TREE, GIT_PREFIX, GIT_COMMON_DIR and the
  object-dir variables.
- test_gen_config_deploy: its git helper and detector runner drop all
  GIT_* from os.environ, so any wrapper that exports them is safe too.
- Regression test: a nested `git init` under a hook-style GIT_DIR lands
  in its own directory.

Refs #1365
2026-10-27 10:58:22 -04:00

219 lines
7.9 KiB
Python

"""Tests for the generated Deploy workflow's change detection.
``dev/scripts/backends/gitea.py::_gen_deploy`` used to gate every image
job on ``contains(github.event.head_commit.modified, ...)``. That list
only describes the tip commit of a push and is empty for merge commits,
so a PR landed with a merge commit never rebuilt any image. The workflow
now has a ``changes`` job that diffs the pushed range (``before`` → the
new tip) and the image jobs gate on its outputs.
The shape tests guard the generated YAML; the behavioural tests run the
generated detector script in a scratch git repository so the shell logic
is exercised for real (merge commit, direct push, unknown previous tip).
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
_DEV_SCRIPTS = Path(__file__).resolve().parents[2] / "dev" / "scripts"
if str(_DEV_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_DEV_SCRIPTS))
from backends import gitea # noqa: E402
_IMAGES = [
{
"name": "api",
"dockerfile": "infra/images/api.Dockerfile",
"context": ".",
"path_filter": ["infra/images/api.Dockerfile", "src/**", "pyproject.toml"],
},
{
"name": "mc",
"dockerfile": "infra/images/mc.Dockerfile",
"context": "infra/rustfs/",
"path_filter": ["infra/rustfs/**", "infra/images/mc.Dockerfile"],
},
{
"name": "always",
"dockerfile": "infra/images/always.Dockerfile",
"context": ".",
},
]
def _gen() -> tuple[str, dict]:
path, content = gitea._gen_deploy(
_IMAGES,
[_IMAGES[0]],
"git:3000",
"homelab/stack",
"ubuntu-latest",
"latest",
)
return path, yaml.safe_load(content)
def test_case_glob_collapses_double_star():
assert gitea._case_glob("src/**") == "src/*"
assert gitea._case_glob("infra/rustfs/**") == "infra/rustfs/*"
assert gitea._case_glob("pyproject.toml") == "pyproject.toml"
assert gitea._case_glob("a/**/**/b") == "a/*/*/b"
def test_changes_job_exposes_one_output_per_image():
path, wf = _gen()
assert path == ".gitea/workflows/deploy.yml"
changes = wf["jobs"]["changes"]
assert changes["outputs"] == {
"api": "${{ steps.filter.outputs.api }}",
"mc": "${{ steps.filter.outputs.mc }}",
"always": "${{ steps.filter.outputs.always }}",
}
step = next(s for s in changes["steps"] if s.get("id") == "filter")
assert step["env"] == {"BEFORE": "${{ github.event.before }}"}
assert (
"match api 'infra/images/api.Dockerfile' 'src/*' 'pyproject.toml'"
in (step["run"])
)
assert "match mc 'infra/rustfs/*' 'infra/images/mc.Dockerfile'" in step["run"]
# An image without path filters follows every push.
assert 'echo "always=true" >> "$GITHUB_OUTPUT"' in step["run"]
def test_image_jobs_gate_on_changes_outputs_not_head_commit():
_, wf = _gen()
for name in ("api", "mc", "always"):
job = wf["jobs"][name]
assert job["needs"] == ["changes"]
assert job["if"] == f"needs.changes.outputs.{name} == 'true'"
assert wf["jobs"]["report"]["needs"] == ["changes", "api", "mc", "always"]
_, content = gitea._gen_deploy(
_IMAGES, [], "git:3000", "homelab/stack", "ubuntu-latest", "latest"
)
assert "contains(github.event.head_commit" not in content
# ── Behavioural: run the detector script against real git history ──────
def _clean_env() -> dict[str, str]:
"""os.environ without GIT_* — under a pre-commit hook GIT_DIR and
GIT_INDEX_FILE point at the real repo, and every scratch-repo command
here would commit into it instead."""
return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
def _git(*args: str, cwd: Path) -> str:
env = {
**_clean_env(),
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.invalid",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.invalid",
}
return subprocess.run(
["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True
).stdout.strip()
def _commit_file(repo: Path, rel: str, msg: str) -> str:
target = repo / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"{msg}\n")
_git("add", "-A", cwd=repo)
_git("commit", "-q", "-m", msg, cwd=repo)
return _git("rev-parse", "HEAD", cwd=repo)
@pytest.fixture
def history(tmp_path: Path) -> dict[str, object]:
"""A bare 'origin' whose main has: base → (direct push) → merge commit.
Returns the SHAs a push event would carry for each push plus a
shallow clone of the final main, as the runner's checkout leaves it.
"""
origin = tmp_path / "origin.git"
_git("init", "-q", "--bare", "-b", "main", str(origin), cwd=tmp_path)
# Shallow fetches by raw SHA need the server's consent; Gitea grants
# it via protocol v2, the local file transport must be told.
_git("config", "uploadpack.allowAnySHA1InWant", "true", cwd=origin)
work = tmp_path / "work"
_git("clone", "-q", str(origin), str(work), cwd=tmp_path)
base = _commit_file(work, "README.md", "base")
_commit_file(work, "docs/index.md", "docs only")
direct = _commit_file(work, "infra/rustfs/policy.json", "mc config")
_git("push", "-q", "origin", "main", cwd=work)
_git("checkout", "-q", "-b", "feature", cwd=work)
_commit_file(work, "src/llm/rag.py", "feature: rag")
_commit_file(work, "tests/llm/test_rag.py", "feature: test")
_git("checkout", "-q", "main", cwd=work)
_git("merge", "-q", "--no-ff", "-m", "merge feature", "feature", cwd=work)
merge = _git("rev-parse", "HEAD", cwd=work)
_git("push", "-q", "origin", "main", cwd=work)
shallow = tmp_path / "shallow"
_git("clone", "-q", "--depth", "1", str(origin), str(shallow), cwd=tmp_path)
assert _git("rev-parse", "HEAD", cwd=shallow) == merge
return {"base": base, "direct": direct, "merge": merge, "shallow": shallow}
def _run_detector(checkout: Path, before: str, sha: str, tmp_path: Path) -> dict:
_, wf = _gen()
step = next(s for s in wf["jobs"]["changes"]["steps"] if s.get("id") == "filter")
out_file = tmp_path / "github_output"
out_file.write_text("")
proc = subprocess.run(
["bash", "-e", "-c", step["run"]],
cwd=checkout,
env={
**_clean_env(),
"BEFORE": before,
"GITHUB_SHA": sha,
"GITHUB_OUTPUT": str(out_file),
},
capture_output=True,
text=True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
outputs = dict(
line.split("=", 1) for line in out_file.read_text().splitlines() if line
)
return {"outputs": outputs, "log": proc.stdout}
def test_merge_commit_push_rebuilds_images_the_branch_touched(history, tmp_path):
"""The push that lands a merge commit must see every file the branch
brought in, which `head_commit.modified` never did."""
res = _run_detector(
history["shallow"], history["direct"], history["merge"], tmp_path
)
assert res["outputs"] == {"api": "true", "mc": "false", "always": "true"}
assert "src/llm/rag.py" in res["log"]
assert "tests/llm/test_rag.py" in res["log"]
def test_multi_commit_direct_push_covers_the_whole_range(history, tmp_path):
"""A push of several plain commits is judged on all of them, not
only the tip (the tip here touched infra/rustfs, an earlier one docs)."""
shallow = history["shallow"]
res = _run_detector(shallow, history["base"], history["direct"], tmp_path)
assert res["outputs"] == {"api": "false", "mc": "true", "always": "true"}
assert "docs/index.md" in res["log"]
def test_unknown_previous_tip_builds_everything(history, tmp_path):
for before in ("", "0" * 40, "f" * 40):
res = _run_detector(history["shallow"], before, history["merge"], tmp_path)
assert res["outputs"] == {"api": "true", "mc": "true", "always": "true"}, before