US08-04: Publish and Deploy from Gitea Actions
Some checks failed
Test / suites (pull_request) Failing after 5m14s
Some checks failed
Test / suites (pull_request) Failing after 5m14s
This commit is contained in:
99
.gitea/workflows/deploy.yml
Normal file
99
.gitea/workflows/deploy.yml
Normal 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
51
.gitea/workflows/test.yml
Normal 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
|
||||
49
README.md
49
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:<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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
210
tests/integration/test_deploy_workflows.py
Normal file
210
tests/integration/test_deploy_workflows.py
Normal 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"
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user