diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..754af3e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,99 @@ +# Publish and redeploy (US08-04). Adapted from the crowdsec-admin deploy workflow: +# build, log in to the Gitea registry, push, trigger the Portainer webhook, prune. +# The difference is the gate — this project has a required suite that must not be +# skipped, so publishing happens only after `Test` (`.gitea/workflows/test.yml`) +# succeeded on `main`, never on the push itself. +# +# The stack is managed by Portainer from git (`docker-compose.yml`), and the runtime +# secrets it needs — vision key, Immich key, access secret — live in the Portainer +# stack's environment. They are deliberately not repository secrets and are not in the +# image: rotation stays in one place, and a repository read never discloses them. +# +# Repository secrets required: +# REGISTRY_USER user with write:package on the registry +# REGISTRY_TOKEN that user's token +# PORTAINER_WEBHOOK_URL POST URL from the stack's auto-update setting + +name: Deploy + +on: + workflow_run: + workflows: + - Test + types: + - completed + branches: + - main + workflow_dispatch: + inputs: + dry_run: + description: Build and push a scratch tag only — leave `latest` and the running stack alone + type: boolean + default: true + +concurrency: + # Deliberately not keyed by commit: the point is that two deploys of *different* + # commits cannot overlap. Queued, not cancelled — a half-pushed tag set is worse + # than a late one. + group: deploy + cancel-in-progress: false + +env: + IMAGE: git.domverse-berlin.eu/domverse/photoanalyzer + # The commit that was tested, not whatever `main` points at by the time this starts. + SHA: ${{ gitea.event.workflow_run.head_sha || gitea.sha }} + +jobs: + publish: + # A completed `Test` run is not a passing one. + if: >- + (gitea.event_name == 'workflow_run' && gitea.event.workflow_run.conclusion == 'success') + || (gitea.event_name == 'workflow_dispatch' && inputs.dry_run == false) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.SHA }} + + - name: Log in to the Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.domverse-berlin.eu -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build and push + # The commit tag is pushed first, so a `latest` that exists is always a tag + # that also exists under its own commit — which is what makes a rollback a + # tag change rather than a rebuild. + run: | + docker build -t "$IMAGE:$SHA" -t "$IMAGE:latest" . + docker push "$IMAGE:$SHA" + docker push "$IMAGE:latest" + + - name: Trigger the Portainer redeploy + # --fail turns an HTTP error into a non-zero exit: a redeploy that did not + # happen must not read as a green deploy. + run: curl -sS --fail -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" + + - name: Prune dangling images + # Untagged layers only. Published tags are the rollback history; `-a` would + # delete exactly the images this workflow exists to keep. + run: docker image prune -f + + dry-run: + # Manual only, and the default: prove the image still builds and the registry + # still accepts it without moving `latest` or touching the running stack. + if: gitea.event_name == 'workflow_dispatch' && inputs.dry_run + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to the Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.domverse-berlin.eu -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build and push a scratch tag + run: | + docker build -t "$IMAGE:scratch-$SHA" . + docker push "$IMAGE:scratch-$SHA" + + - name: Prune dangling images + run: docker image prune -f diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..b7220cf --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,51 @@ +# The test gate. Deploy waits for this workflow by name (`.gitea/workflows/deploy.yml` +# triggers on `workflow_run: [Test]`), so renaming it here without renaming it there +# would leave `main` publishing without a suite. `tests/integration/test_deploy_workflows.py` +# asserts both halves of that link, and that the commands below are still the ones +# configured in `work_item/.work-item.yml`. + +name: Test + +on: + pull_request: + push: + branches: + - main + +concurrency: + # One run per branch; a newer push makes the older run's answer irrelevant. + group: test-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + suites: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install exiftool + # The EXIF checkpoints are the safety invariant of every metadata stage; a run + # without exiftool would skip them for a reason the release gate accepts. + run: | + SUDO= + if [ "$(id -u)" -ne 0 ]; then SUDO=sudo; fi + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends libimage-exiftool-perl + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install the application and its test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[test]' + python -m playwright install --with-deps chromium + + - name: Helper suite + run: work_item/scripts/python -m unittest discover -s work_item/tests -v + + - name: Application suite + run: work_item/scripts/python -m pytest tests -q diff --git a/README.md b/README.md index 5197f07..0e818a9 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,54 @@ service the command borrows the image and mounts from. `restore` is deliberately an API call: it replaces the state of an installation and belongs to a stopped one, so stop `api` and `worker` first and restart them against the restored directory. -Publishing and deploying the image is US08-04. +## Publishing and deploying (US08-04) + +Two workflows in `.gitea/workflows/` make `main` the only path to the running stack: + +| workflow | runs on | does | +|---|---|---| +| `test.yml` (**Test**) | every pull request, and every push to `main` | installs exiftool, the package, and Chromium, then runs the suites configured in `work_item/.work-item.yml` | +| `deploy.yml` (**Deploy**) | a **successful** `Test` run on `main`, or manual dispatch | builds the image, pushes it, triggers the Portainer webhook, prunes dangling layers | + +Deploy waits for `Test` through `workflow_run`, so publishing is downstream of a green +suite rather than of a push. `completed` is not `success`: the publish job runs only on +`conclusion == 'success'`. Because an unmatched `workflow_run` filter does not fail but +simply never fires, `tests/integration/test_deploy_workflows.py` asserts the link from +both ends — renaming either workflow, or dropping one of the configured suites out of +the test job, fails that test rather than silently unhooking the gate. + +The image is `git.domverse-berlin.eu/domverse/photoanalyzer`, tagged with the commit +SHA and `latest`. The commit tag is pushed first, so a `latest` that exists always has +a commit tag beside it and **a rollback is a tag change, not a rebuild**: + +```bash +docker pull git.domverse-berlin.eu/domverse/photoanalyzer: +docker tag git.domverse-berlin.eu/domverse/photoanalyzer: \ + git.domverse-berlin.eu/domverse/photoanalyzer:latest +docker push git.domverse-berlin.eu/domverse/photoanalyzer:latest +# then trigger the Portainer webhook, or redeploy the stack from Portainer +``` + +Manual dispatch defaults to **dry run**: it builds and pushes `scratch-` only, and +touches neither `latest` nor the running stack. Clear the `dry_run` input to publish by +hand. A workflow-level `concurrency: deploy` group with `cancel-in-progress: false` +queues deploys instead of overlapping them, so two commits can never race to `latest`. + +### Secrets + +Repository secrets — used only by the pipeline: + +| secret | purpose | +|---|---| +| `REGISTRY_USER` | registry user with `write:package` | +| `REGISTRY_TOKEN` | that user's token, passed on stdin, never as an argument | +| `PORTAINER_WEBHOOK_URL` | the stack's auto-update POST URL; `curl --fail` makes a refused redeploy a failed workflow | + +Runtime secrets — `PHOTO_PIPELINE_VISION_API_KEY`, `PHOTO_PIPELINE_IMMICH_API_KEY`, +`PHOTO_PIPELINE_ACCESS_SECRET` — are **not** repository secrets and are not in the +image. The stack is managed by Portainer from git (`docker-compose.yml`), and those +values live in the Portainer stack's environment, so rotation is one place and a +repository read discloses nothing. A test asserts the workflows never name them. ## Testing diff --git a/pyproject.toml b/pyproject.toml index 9966324..b3a98ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ test = [ "httpx>=0.27", "playwright>=1.40", "pytest-playwright>=0.4", + # The composition and the workflows are configuration, and the tests that hold + # them to their promises read them (US08-03, US08-04). + "pyyaml>=6", ] [build-system] diff --git a/tests/integration/test_deploy_workflows.py b/tests/integration/test_deploy_workflows.py new file mode 100644 index 0000000..f6a4c28 --- /dev/null +++ b/tests/integration/test_deploy_workflows.py @@ -0,0 +1,210 @@ +"""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" diff --git a/tests/story_traceability.json b/tests/story_traceability.json index f164771..d9faa45 100644 --- a/tests/story_traceability.json +++ b/tests/story_traceability.json @@ -186,10 +186,12 @@ "US08-03": [ "tests/integration/test_compose_runtime.py", "tests/e2e/test_compose_stack.py" + ], + "US08-04": [ + "tests/integration/test_deploy_workflows.py" ] }, "planned": [ - "US08-04", "US08-05" ], "_planned_comment": "Accepted backlog stories that are not implemented yet. The release gate (US07-07) requires every story file to be either mapped to tests or listed here, so an unimplemented story is a visible decision rather than a hole in the matrix."