"""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"