US08-04: Publish and Deploy from Gitea Actions
Some checks failed
Test / suites (pull_request) Failing after 5m14s

This commit is contained in:
2026-08-19 20:35:59 +02:00
parent 1632544132
commit b57767e3bd
6 changed files with 414 additions and 2 deletions

View File

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

51
.gitea/workflows/test.yml Normal file
View File

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

View File

@@ -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:<known-good-sha>
docker tag git.domverse-berlin.eu/domverse/photoanalyzer:<known-good-sha> \
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-<sha>` 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

View File

@@ -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]

View File

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

View File

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