Files
Proxmox-AIS-Server/tests/test_ci.py
T
BartelLuis 9c528c6eca
CI / container-policy (push) Successful in 8s
CI / javascript-check (push) Successful in 18s
CI / python-tests (push) Failing after 4m44s
CI / container-verify (push) Skipped
CI / container-publish (push) Skipped
ci: migrate workflows to Gitea Actions
2026-09-14 20:23:20 +02:00

496 lines
22 KiB
Python

"""Exercise publication gates with a fake Docker CLI, never a real daemon."""
import os
from pathlib import Path
import shutil
import subprocess
import tomllib
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
SHA = "1234567890abcdef1234567890abcdef12345678"
REGISTRY_HOST = "gitlab.bartelluis.de"
REGISTRY_IMAGE = f"{REGISTRY_HOST}/team/proxmox-ais"
DIGEST = f"{REGISTRY_IMAGE}@sha256:{'a' * 64}"
PASSWORD = "dummy-ci-token-$()-do-not-print"
def posix_shell():
if os.name == "nt":
shell = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
if not shell.is_file():
pytest.skip("CI shell tests need Git Bash on Windows")
else:
shell = shutil.which("sh")
if not shell:
pytest.skip("CI shell tests need a POSIX shell")
return str(shell)
@pytest.fixture
def container_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / "bin").mkdir()
(tmp_path / "tmp").mkdir()
shutil.copyfile(ROOT / "ci/container.sh", tmp_path / "ci/container.sh")
shutil.copyfile(ROOT / "ci/publish-policy.sh", tmp_path / "ci/publish-policy.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
smoke_source = (ROOT / "tests/container_smoke.py").read_bytes()
(tmp_path / "tests/container_smoke.py").write_bytes(smoke_source)
docker = tmp_path / "bin/docker"
docker.write_text(
"""#!/bin/sh
set -eu
{
printf '%s' "$1"
for argument in "$@"; do printf '\\t%s' "$argument"; done
printf '\\n'
} >> "$MOCK_DOCKER_LOG"
if [ "$1" = "${MOCK_FAIL_COMMAND:-}" ]; then exit 37; fi
case "$1" in
run)
cat > "$MOCK_SMOKE_STDIN"
exit "${MOCK_RUN_EXIT:-0}"
;;
login)
cat > "$MOCK_LOGIN_STDIN"
printf '%s' "$DOCKER_CONFIG" > "$MOCK_DOCKER_CONFIG"
printf '{"auths":{"test":"dummy-credentials"}}' > "$DOCKER_CONFIG/config.json"
;;
image)
printf '%s\\n' "$MOCK_REPO_DIGESTS"
;;
esac
""",
encoding="utf-8",
newline="\n",
)
docker.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"TMPDIR": (tmp_path / "tmp").as_posix(),
"GITHUB_SHA": SHA,
"GITHUB_RUN_ID": "210",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "container_publish",
"GITHUB_REPOSITORY": "Team/Proxmox-AIS",
"GITHUB_SERVER_URL": "https://gitlab.bartelluis.de",
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF_PROTECTED": "false",
"GITHUB_REF_TYPE": "branch",
"GITHUB_REF_NAME": "main",
"DEFAULT_BRANCH": "main",
"PUBLISH_IMAGES": "true",
"GITHUB_ACTOR": "ci-user",
"REGISTRY_HOST": REGISTRY_HOST,
"REGISTRY_USERNAME": "registry-owner",
"REGISTRY_TOKEN": PASSWORD,
"MOCK_DOCKER_LOG": (tmp_path / "docker.log").as_posix(),
"MOCK_SMOKE_STDIN": (tmp_path / "smoke.stdin").as_posix(),
"MOCK_LOGIN_STDIN": (tmp_path / "login.stdin").as_posix(),
"MOCK_DOCKER_CONFIG": (tmp_path / "docker-config.path").as_posix(),
"MOCK_REPO_DIGESTS": DIGEST,
}
def run(mode="publish", **overrides):
effective_environment = {**environment, **overrides}
for name in [name for name, value in effective_environment.items() if value is None]:
del effective_environment[name]
result = subprocess.run(
[str(shell), "ci/container.sh", mode],
cwd=tmp_path,
env=effective_environment,
capture_output=True,
text=True,
timeout=30,
)
log = tmp_path / "docker.log"
calls = [line.split("\t")[1:] for line in log.read_text().splitlines()] if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path, smoke_source=smoke_source)
return run
def test_default_branch_publishes_only_after_hardened_image_smoke(container_ci):
outcome = container_ci()
assert outcome.result.returncode == 0, outcome.result.stderr
commands = [call[0] for call in outcome.calls]
assert commands == ["build", "run", "login", "tag", "push", "tag", "push", "image", "rm", "image"]
build_image = f"{REGISTRY_IMAGE}:ci-210-1-container_publish"
assert outcome.calls[-1] == ["image", "rm", build_image]
build = outcome.calls[0]
assert build[build.index("--platform") + 1] == "linux/amd64"
assert "--provenance=false" in build and "--sbom=false" in build
assert f"org.opencontainers.image.revision={SHA}" in build
assert "org.opencontainers.image.source=https://gitlab.bartelluis.de/Team/Proxmox-AIS" in build
assert build[build.index("--tag") + 1] == build_image
smoke = outcome.calls[1]
for option in ["--read-only", "--cap-drop", "ALL", "no-new-privileges:true", "/tmp:rw,noexec,nosuid,size=128m"]:
assert option in smoke
assert smoke[smoke.index("--entrypoint") + 1] == "python"
assert smoke[smoke.index("--entrypoint") + 2] == build_image
assert all(call[1] == build_image for call in outcome.calls if call[0] == "tag")
assert "--volume" not in smoke and "-v" not in smoke
assert (outcome.root / "smoke.stdin").read_bytes() == outcome.smoke_source
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:edge"
]
assert (outcome.root / "deploy.env").read_text() == f"PROVISIONER_IMAGE={DIGEST}\n"
assert "ci-210-1-container_publish" in (outcome.root / "build.env").read_text()
assert outcome.calls[2] == ["login", REGISTRY_HOST, "--username", "registry-owner", "--password-stdin"]
assert (outcome.root / "login.stdin").read_text() == PASSWORD
assert PASSWORD not in outcome.result.stdout + outcome.result.stderr + (outcome.root / "docker.log").read_text()
config_path = Path((outcome.root / "docker-config.path").read_text())
assert not config_path.exists(), "Temporary registry credentials must be removed"
def test_release_tag_matches_project_version_and_does_not_move_edge(container_ci):
outcome = container_ci(GITHUB_REF_TYPE="tag", GITHUB_REF_NAME=f"v{VERSION}")
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:{VERSION}"
]
def test_pull_request_verifies_without_registry_credentials(container_ci):
outcome = container_ci(
"verify", GITHUB_EVENT_NAME="pull_request", GITHUB_REF_NAME="1/head",
PUBLISH_IMAGES=None, GITHUB_ACTOR=None, REGISTRY_USERNAME=None, REGISTRY_TOKEN=None,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[0] for call in outcome.calls] == ["build", "run", "rm", "image"]
assert "--provenance=false" in outcome.calls[0] and "--sbom=false" in outcome.calls[0]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
assert (outcome.root / "build.env").is_file()
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("protected", ["false", None])
def test_gitea_publication_opt_in_does_not_depend_on_ref_protected(container_ci, protected):
outcome = container_ci(GITHUB_REF_PROTECTED=protected, GITHUB_ACTOR=None)
assert outcome.result.returncode == 0, outcome.result.stderr
assert outcome.calls[2] == ["login", REGISTRY_HOST, "--username", "registry-owner", "--password-stdin"]
assert (outcome.root / "deploy.env").is_file()
@pytest.mark.parametrize("registry_host", [
None, "", "https://registry.example.test", "registry.example.test/images",
"registry.example.test\nother.example.test", "Registry.example.test",
"registry.example.test:invalid", "registry.example.test:",
"-registry.example.test", "user@registry.example.test",
])
def test_invalid_registry_host_fails_before_build(container_ci, registry_host):
outcome = container_ci(REGISTRY_HOST=registry_host)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not (outcome.root / "deploy.env").exists()
def test_registry_port_is_used_for_login_tags_and_deployment_digest(container_ci):
registry_host = "registry.example.test:5000"
registry_image = f"{registry_host}/team/proxmox-ais"
digest = f"{registry_image}@sha256:{'b' * 64}"
outcome = container_ci(REGISTRY_HOST=registry_host, MOCK_REPO_DIGESTS=digest)
assert outcome.result.returncode == 0, outcome.result.stderr
assert outcome.calls[2] == ["login", registry_host, "--username", "registry-owner", "--password-stdin"]
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{registry_image}:sha-{SHA}", f"{registry_image}:edge"
]
assert (outcome.root / "deploy.env").read_text() == f"PROVISIONER_IMAGE={digest}\n"
@pytest.mark.parametrize("overrides", [
{"GITHUB_REF_NAME": "feature/ci"},
{"GITHUB_REF_NAME": f"v{VERSION}"},
{"GITHUB_REF_NAME": None},
{"DEFAULT_BRANCH": ""},
{"PUBLISH_IMAGES": "false"},
{"PUBLISH_IMAGES": None},
{"PUBLISH_IMAGES": "TRUE"},
{"PUBLISH_IMAGES": "1"},
{"GITHUB_EVENT_NAME": "pull_request"},
{"GITHUB_EVENT_NAME": "pull_request_target"},
{"GITHUB_EVENT_NAME": "workflow_run"},
{"GITHUB_EVENT_NAME": "schedule"},
{"GITHUB_EVENT_NAME": "web"},
{"GITHUB_EVENT_NAME": None},
{"GITHUB_REF_TYPE": None},
{"GITHUB_REF_TYPE": "pull_request"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "main"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v9999.9999.9999"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "nightly"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0-rc1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v00.1.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.01.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.00"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": f"v{VERSION}", "PUBLISH_IMAGES": "false"},
{"REGISTRY_TOKEN": None},
{"REGISTRY_USERNAME": None},
{"GITHUB_SHA": "1234"},
{"GITHUB_RUN_ID": "invalid"},
{"GITHUB_RUN_ATTEMPT": "../2"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"GITHUB_REPOSITORY": "team"},
{"GITHUB_REPOSITORY": "team/repo/extra"},
{"GITHUB_REPOSITORY": "team/repo:tag"},
])
def test_unauthorized_or_invalid_publication_fails_before_build(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("ref_type, ref_name", [("branch", "main"), ("tag", f"v{VERSION}")])
def test_manual_run_can_publish_opted_in_default_branch_or_release(container_ci, ref_type, ref_name):
outcome = container_ci(
GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_TYPE=ref_type, GITHUB_REF_NAME=ref_name,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert (outcome.root / "deploy.env").is_file()
def test_manual_feature_branch_cannot_publish(container_ci):
outcome = container_ci(GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_NAME="feature/ci")
assert outcome.result.returncode != 0
assert not outcome.calls
@pytest.mark.parametrize("attempt, job", [("2", "container_publish"), ("1", "container-verify")])
def test_container_names_include_run_attempt_and_job(container_ci, attempt, job):
outcome = container_ci("verify", GITHUB_RUN_ATTEMPT=attempt, GITHUB_JOB=job)
assert outcome.result.returncode == 0, outcome.result.stderr
build_image = f"{REGISTRY_IMAGE}:ci-210-{attempt}-{job}"
build, smoke = outcome.calls[:2]
assert build[build.index("--tag") + 1] == build_image
assert smoke[smoke.index("--name") + 1] == f"ais-smoke-210-{attempt}-{job}"
assert outcome.calls[-1] == ["image", "rm", build_image]
@pytest.mark.parametrize("overrides", [
{"MOCK_FAIL_COMMAND": "build"},
{"MOCK_RUN_EXIT": "19"},
])
def test_failed_build_or_smoke_never_logs_in_or_pushes(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not any(call[0] in {"login", "tag", "push"} for call in outcome.calls)
assert not (outcome.root / "build.env").exists()
assert not (outcome.root / "deploy.env").exists()
if "MOCK_RUN_EXIT" in overrides:
assert outcome.calls[-2] == ["rm", "--force", "ais-smoke-210-1-container_publish"]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
def test_push_failure_does_not_create_deployment_artifact_and_cleans_credentials(container_ci):
outcome = container_ci(MOCK_FAIL_COMMAND="push")
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
assert not Path((outcome.root / "docker-config.path").read_text()).exists()
assert len([call for call in outcome.calls if call[0] == "push"]) == 1
@pytest.mark.parametrize("digests", ["", "other.example.test/image@sha256:" + "a" * 64, REGISTRY_IMAGE + "@sha256:invalid"])
def test_missing_or_invalid_registry_digest_fails_without_artifact(container_ci, digests):
outcome = container_ci(MOCK_REPO_DIGESTS=digests)
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("overrides, expected", [
({}, "true"),
({"GITHUB_EVENT_NAME": "workflow_dispatch"}, "true"),
({"GITHUB_REF_NAME": "trunk", "DEFAULT_BRANCH": "trunk"}, "true"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.0.0"}, "true"),
({"GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v12.34.56"}, "true"),
({"GITHUB_REF_PROTECTED": None}, "true"),
({"PUBLISH_IMAGES": None}, "false"),
({"PUBLISH_IMAGES": ""}, "false"),
({"PUBLISH_IMAGES": "false"}, "false"),
({"PUBLISH_IMAGES": "TRUE"}, "false"),
({"PUBLISH_IMAGES": "1"}, "false"),
({"PUBLISH_IMAGES": "true\n"}, "false"),
({"PUBLISH_IMAGES": "false", "GITHUB_REF_PROTECTED": "true"}, "false"),
({"GITHUB_EVENT_NAME": None}, "false"),
({"GITHUB_EVENT_NAME": "pull_request"}, "false"),
({"GITHUB_EVENT_NAME": "pull_request_target"}, "false"),
({"GITHUB_EVENT_NAME": "workflow_run"}, "false"),
({"GITHUB_EVENT_NAME": "schedule"}, "false"),
({"GITHUB_REF_TYPE": None}, "false"),
({"GITHUB_REF_TYPE": "pull_request"}, "false"),
({"GITHUB_REF_NAME": "feature/ci"}, "false"),
({"GITHUB_REF_NAME": None}, "false"),
({"DEFAULT_BRANCH": None}, "false"),
({"DEFAULT_BRANCH": "", "GITHUB_REF_NAME": ""}, "false"),
({"GITHUB_REF_NAME": "main;touch injected"}, "false"),
({"GITHUB_REF_NAME": "$(touch injected)"}, "false"),
({"GITHUB_REF_NAME": "`touch injected`"}, "false"),
({"GITHUB_REF_NAME": "release/$(touch${IFS}injected)", "DEFAULT_BRANCH": "release/$(touch${IFS}injected)"}, "true"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "main"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v01.2.3"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.02.3"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.03"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.3.4"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.3-rc1"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.3+build"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.3\nother"}, "false"),
({"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v1.2.3$(touch injected)"}, "false"),
])
def test_publication_policy_matrix(tmp_path, overrides, expected):
shutil.copyfile(ROOT / "ci/publish-policy.sh", tmp_path / "publish-policy.sh")
environment = {
**os.environ,
"PUBLISH_IMAGES": "true",
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF_TYPE": "branch",
"GITHUB_REF_NAME": "main",
"DEFAULT_BRANCH": "main",
"GITHUB_REF_PROTECTED": "false",
**overrides,
}
environment = {name: value for name, value in environment.items() if value is not None}
result = subprocess.run(
[posix_shell(), "publish-policy.sh"], cwd=tmp_path, env=environment,
capture_output=True, text=True, timeout=10,
)
assert result.returncode == 0, result.stderr
assert result.stdout == f"{expected}\n"
assert not (tmp_path / "injected").exists(), "Ref names must never be evaluated as shell code"
@pytest.fixture
def python_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "bin").mkdir()
shutil.copyfile(ROOT / "ci/python-tests.sh", tmp_path / "ci/python-tests.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
venv_python = tmp_path / "venv-python"
venv_python.write_text(
"""#!/bin/sh
set -eu
printf '%s\\n' "$*" >> "$MOCK_PYTHON_LOG"
case "$2" in
pip) exit "${MOCK_PIP_EXIT:-0}" ;;
pytest)
printf '<testsuite tests="1" />\\n' > reports/pytest.xml
exit "${MOCK_PYTEST_EXIT:-0}"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python = tmp_path / "bin/python3"
python.write_text(
"""#!/bin/sh
set -eu
case "$1" in
-c) exit "${MOCK_PYTHON_VERSION_EXIT:-0}" ;;
-m)
[ "$2" = venv ]
printf '%s' "$3" > "$MOCK_VENV_PATH"
mkdir -p "$3/bin"
cp "$MOCK_VENV_PYTHON" "$3/bin/python"
chmod +x "$3/bin/python"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python.chmod(0o755)
for name in ["bash", "openssl", "ssh-keygen"]:
executable = tmp_path / "bin" / name
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n")
executable.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"GITHUB_WORKSPACE": tmp_path.as_posix(),
"GITHUB_RUN_ID": "543",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "python_tests",
"MOCK_PYTHON_LOG": (tmp_path / "python.log").as_posix(),
"MOCK_VENV_PATH": (tmp_path / "venv.path").as_posix(),
"MOCK_VENV_PYTHON": venv_python.as_posix(),
}
def run(missing_tool=None, **overrides):
effective_environment = {**environment, **overrides}
effective_environment = {name: value for name, value in effective_environment.items() if value is not None}
command = [shell, "ci/python-tests.sh"]
if missing_tool:
(tmp_path / "bin" / missing_tool).unlink()
# Git Bash adds its own tools to PATH on Windows startup. Limit
# PATH inside the shell to model a genuinely missing system tool.
command = [shell, "-c", 'PATH="$(pwd -P)/bin"\nexport PATH\n. ci/python-tests.sh']
result = subprocess.run(
command, cwd=tmp_path, env=effective_environment,
capture_output=True, text=True, timeout=30,
)
log = tmp_path / "python.log"
calls = log.read_text().splitlines() if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path)
return run
@pytest.mark.parametrize("overrides, expected_code, expected_calls", [
({}, 0, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PYTEST_EXIT": "1"}, 1, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PIP_EXIT": "42"}, 42, ["-m pip install .[dev]"]),
])
def test_python_job_isolates_dependencies_and_cleans_venv(python_ci, overrides, expected_code, expected_calls):
outcome = python_ci(**overrides)
assert outcome.result.returncode == expected_code, outcome.result.stderr
assert outcome.calls == expected_calls
assert (outcome.root / "venv.path").is_file()
assert ".venv-ci-543-1-python_tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*")), "The job must clean its virtual environment even when tests or installation fail"
assert (outcome.root / "reports/pytest.xml").exists() == ("MOCK_PIP_EXIT" not in overrides)
@pytest.mark.parametrize("required_tool", ["python3", "bash", "openssl", "ssh-keygen"])
def test_python_job_requires_system_tools_instead_of_skipping_tests(python_ci, required_tool):
outcome = python_ci(missing_tool=required_tool)
assert outcome.result.returncode != 0
assert f"Required runner tool is missing: {required_tool}" in outcome.result.stderr
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
@pytest.mark.parametrize("overrides", [
{"GITHUB_RUN_ID": "../invalid"},
{"GITHUB_RUN_ATTEMPT": "invalid"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"MOCK_PYTHON_VERSION_EXIT": "1"},
])
def test_python_job_rejects_invalid_job_or_python_version_before_installing(python_ci, overrides):
outcome = python_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
def test_python_job_names_venv_for_the_run_attempt_and_job(python_ci):
outcome = python_ci(GITHUB_RUN_ATTEMPT="2", GITHUB_JOB="python-tests")
assert outcome.result.returncode == 0, outcome.result.stderr
assert ".venv-ci-543-2-python-tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*"))