Files
photoanalyzer/tests/integration/test_deploy_workflows.py
domverse b57767e3bd
Some checks failed
Test / suites (pull_request) Failing after 5m14s
US08-04: Publish and Deploy from Gitea Actions
2026-08-19 20:35:59 +02:00

211 lines
9.1 KiB
Python

"""US08-04: the deployment pipeline's contract, read from the workflow files.
A CI/CD pipeline is the one piece of this repository whose failure mode is silence.
A renamed workflow, a renamed job, a trigger that quietly stopped matching, and the
suite is no longer between `main` and the registry — with nothing red anywhere to say
so. So the link is asserted from both ends here: the deploy workflow waits for the
test workflow *by name*, and that name is read out of the test workflow itself.
The same applies to the commands: `work_item/.work-item.yml` is where the required
suites are configured, and the test workflow has to run those, not a copy of them that
was true once.
Nothing here talks to Gitea, the registry, or Portainer. What is checkable offline is
what the files promise; whether the runner honours them is the deployment's own
evidence (US08-05).
"""
from __future__ import annotations
import re
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parents[2]
WORKFLOWS = REPO / ".gitea" / "workflows"
TEST_FILE = WORKFLOWS / "test.yml"
DEPLOY_FILE = WORKFLOWS / "deploy.yml"
TEST_WORKFLOW = yaml.safe_load(TEST_FILE.read_text())
DEPLOY_WORKFLOW = yaml.safe_load(DEPLOY_FILE.read_text())
WORK_ITEM = yaml.safe_load((REPO / "work_item" / ".work-item.yml").read_text())
REGISTRY = "git.domverse-berlin.eu"
def triggers(workflow: dict) -> dict:
"""The `on:` block. PyYAML resolves the bare key `on` to ``True``."""
return workflow[True] if True in workflow else workflow["on"]
def steps(workflow: dict, job: str) -> list[dict]:
return workflow["jobs"][job]["steps"]
def run_script(workflow: dict, job: str) -> str:
"""Every `run:` in a job, as one blob — what the runner would execute."""
return "\n".join(step["run"] for step in steps(workflow, job) if "run" in step)
# ── the gate ─────────────────────────────────────────────────────────────────
def test_the_test_workflow_runs_on_pull_requests_and_on_main():
on = triggers(TEST_WORKFLOW)
assert "pull_request" in on
assert on["push"]["branches"] == ["main"]
def test_the_test_workflow_runs_the_configured_required_suites():
required = WORK_ITEM["workflow"]["required_tests"]
assert required, "the helper's required tests are what CI exists to run"
script = run_script(TEST_WORKFLOW, "suites")
for command in required:
assert command in script, f"CI does not run the configured suite: {command}"
def test_the_deploy_workflow_waits_for_the_test_workflow_by_its_real_name():
# Renaming either side without the other breaks this, which is the point: an
# unmatched `workflow_run` filter does not fail, it simply never fires.
awaited = triggers(DEPLOY_WORKFLOW)["workflow_run"]
assert awaited["workflows"] == [TEST_WORKFLOW["name"]]
assert awaited["types"] == ["completed"]
assert awaited["branches"] == ["main"], "only main is deployable"
def test_publishing_requires_the_test_run_to_have_succeeded_and_not_merely_finished():
condition = DEPLOY_WORKFLOW["jobs"]["publish"]["if"]
assert "workflow_run.conclusion == 'success'" in condition
# `completed` includes failure and cancellation; the conclusion check is the gate.
assert "gitea.event_name == 'workflow_run'" in condition
def test_the_deploy_workflow_can_also_be_dispatched_by_hand():
assert "workflow_dispatch" in triggers(DEPLOY_WORKFLOW)
# ── what is published ────────────────────────────────────────────────────────
def test_the_image_is_published_under_this_project_s_own_path():
slug = WORK_ITEM["repository"]["slug"]
assert DEPLOY_WORKFLOW["env"]["IMAGE"] == f"{REGISTRY}/{slug}"
def test_both_latest_and_the_commit_are_pushed_so_a_rollback_is_a_tag_change():
script = run_script(DEPLOY_WORKFLOW, "publish")
assert 'docker push "$IMAGE:$SHA"' in script
assert 'docker push "$IMAGE:latest"' in script
# A `latest` without its commit tag would be a version that cannot be rolled back to.
assert script.index('docker push "$IMAGE:$SHA"') < script.index('docker push "$IMAGE:latest"')
def test_the_published_commit_is_the_one_that_was_tested():
assert DEPLOY_WORKFLOW["env"]["SHA"].startswith("${{ gitea.event.workflow_run.head_sha")
checkout = steps(DEPLOY_WORKFLOW, "publish")[0]
assert checkout["with"]["ref"] == "${{ env.SHA }}"
# ── secrets ──────────────────────────────────────────────────────────────────
def test_registry_credentials_and_the_webhook_come_from_repository_secrets():
script = run_script(DEPLOY_WORKFLOW, "publish")
for secret in ("REGISTRY_USER", "REGISTRY_TOKEN", "PORTAINER_WEBHOOK_URL"):
assert f"secrets.{secret}" in script
def test_no_runtime_secret_is_named_by_the_pipeline_at_all():
# The vision key, the Immich key, and the access secret belong to the Portainer
# stack. A workflow that mentions one is a workflow that could carry one.
text = TEST_FILE.read_text() + DEPLOY_FILE.read_text()
for runtime_secret in (
"PHOTO_PIPELINE_VISION_API_KEY",
"PHOTO_PIPELINE_IMMICH_API_KEY",
"PHOTO_PIPELINE_ACCESS_SECRET",
):
assert runtime_secret not in text
def test_the_workflows_carry_no_credential_values():
text = TEST_FILE.read_text() + DEPLOY_FILE.read_text()
for line in text.splitlines():
if re.search(r"(?i)(token|secret|password|api[_-]?key)\s*[:=]", line):
assert "${{ secrets." in line or line.lstrip().startswith("#"), line
def test_the_registry_password_is_never_an_argument():
# An argument is visible in the runner's process list and in `docker login`'s own
# warning; stdin is not.
script = run_script(DEPLOY_WORKFLOW, "publish") + run_script(DEPLOY_WORKFLOW, "dry-run")
assert "--password-stdin" in script
assert "--password " not in script
# ── redeploy, pruning, and overlap ───────────────────────────────────────────
def test_a_failed_webhook_call_fails_the_workflow():
webhook = next(
step
for step in steps(DEPLOY_WORKFLOW, "publish")
if "PORTAINER_WEBHOOK_URL" in step.get("run", "")
)
assert "--fail" in webhook["run"], "curl exits 0 on an HTTP 500 without it"
assert "-k" not in webhook["run"].split(), "the redeploy call verifies TLS"
def test_pruning_removes_dangling_layers_and_never_published_tags():
for job in ("publish", "dry-run"):
script = run_script(DEPLOY_WORKFLOW, job)
assert "docker image prune -f" in script
assert "prune -a" not in script and "--all" not in script
def test_two_deploys_of_different_commits_cannot_overlap():
guard = DEPLOY_WORKFLOW["concurrency"]
assert "sha" not in guard["group"].lower(), "a per-commit group guards nothing"
assert guard["cancel-in-progress"] is False, "a cancelled push leaves half a tag set"
# ── the dry run ──────────────────────────────────────────────────────────────
def test_the_dry_run_is_manual_only_and_defaults_to_on():
condition = DEPLOY_WORKFLOW["jobs"]["dry-run"]["if"]
assert condition == "gitea.event_name == 'workflow_dispatch' && inputs.dry_run"
dispatch = triggers(DEPLOY_WORKFLOW)["workflow_dispatch"]
assert dispatch["inputs"]["dry_run"]["default"] is True
assert dispatch["inputs"]["dry_run"]["type"] == "boolean"
def test_the_dry_run_publishes_a_scratch_tag_and_nothing_else():
script = run_script(DEPLOY_WORKFLOW, "dry-run")
assert 'docker push "$IMAGE:scratch-$SHA"' in script
assert ":latest" not in script, "a dry run that moves latest is a deploy"
assert "PORTAINER_WEBHOOK_URL" not in script, "a dry run never redeploys the stack"
def test_a_manual_deploy_is_the_deliberate_choice_not_the_default():
publish = DEPLOY_WORKFLOW["jobs"]["publish"]["if"]
assert "inputs.dry_run == false" in publish
# ── the files themselves ─────────────────────────────────────────────────────
def test_every_workflow_file_parses_and_declares_a_name_a_trigger_and_a_job():
files = sorted(WORKFLOWS.glob("*.yml")) + sorted(WORKFLOWS.glob("*.yaml"))
assert {path.name for path in files} == {"test.yml", "deploy.yml"}
for path in files:
workflow = yaml.safe_load(path.read_text())
assert workflow["name"], path
assert triggers(workflow), path
assert workflow["jobs"], path
for job_name, job in workflow["jobs"].items():
assert job["runs-on"], f"{path}:{job_name}"
assert job["steps"], f"{path}:{job_name}"
for step in job["steps"]:
assert step.get("name"), f"{path}:{job_name} has an unnamed step"