From b46fbea4c92b14a2f59a399b68fda9a9a7fdcfc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Mon, 12 Oct 2026 20:51:08 +0800 Subject: [PATCH] Retain only the verified production latest image --- docs/deployment/GITEA_CD.md | 28 +++++++++++++--------------- scripts/production-deploy.py | 32 +++++++++++++++++++++----------- tests/test_production_deploy.py | 17 +++++++++++++---- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md index 0d01e45..6f37bfb 100644 --- a/docs/deployment/GITEA_CD.md +++ b/docs/deployment/GITEA_CD.md @@ -107,7 +107,7 @@ been performed during implementation; those are first-run acceptance steps. an immutable rollback tag so normal dangling-image cleanup cannot remove it. 4. Refuse a stale checked-out `main` revision that no longer equals remote main. Build an immutable `release--` tag locally, without a registry - and without changing `latest` or CI image tags. + then point `latest` at the candidate; CI image tags are never changed. 5. Persist a `prepared` journal with old/new runtime configs. Create an additive SQLite online backup under `/data/deploy-backups/.db`. No data/config directory or file is removed, replaced or recursively chowned. @@ -266,7 +266,7 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac `192.0.2.10:18787` binding (not loopback). 4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag - is `pigway-frame-flow:release--`; compare its image ID in + is `pigway-frame-flow:latest`; compare its image ID in Container Manager with the running container. 5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using this same runtime and journal mechanism. The first update records bootstrap as @@ -366,17 +366,15 @@ complete audit succeeded. ### Production image retention -After a deployment passes verification and its success journal is saved, Frame Flow -removes obsolete release and rollback images from the -configured production image repository. It retains the running version and the -previous deployment baseline for one-step rollback. Images referenced by any -container (including stopped containers), images with unrelated tags, and CI -images are protected. Retention does not prune build cache, volumes, or data. -Failed deployments do not run retention; cleanup failure produces a warning and -never rolls back a successfully verified deployment. Historical deployment -journals remain available for audit, but only the latest rollback image is -retained locally; older releases require rebuilding before use. +Production uses `pigway-frame-flow:latest`. Builds first create a unique release +image, then tag it as latest before activation. The old image is pinned during +an update so a failed deployment can restore it. Only after the new version +passes verification are old release/rollback images and duplicate tags removed. +After successful deployment, only the current production image remains; there +is no retained historical image for manual rollback. Rebuild a historical +revision before manually reverting a successful deployment. -The rollback image keeps one tag: its original `release--` -tag with `-rollback` appended. Duplicate release/legacy rollback tags are removed -only after the rollback alias exists, and container-referenced tags remain protected. +Images referenced by other containers (including stopped containers), images +with unrelated tags, and CI resources remain protected. Cleanup never prunes +volumes, data, or shared build cache. Cleanup failure is a warning and does not +roll back a verified deployment. Failed deployments do not run cleanup. diff --git a/scripts/production-deploy.py b/scripts/production-deploy.py index 01f28cd..88dbc1a 100644 --- a/scripts/production-deploy.py +++ b/scripts/production-deploy.py @@ -337,6 +337,12 @@ class Deploy: def rollback(self, previous): self.activate(previous) self.verify(previous) + self.run( + "image", + "tag", + previous["services"]["pigway-frame-flow"]["image"], + self.env["PROD_IMAGE_REPO"] + ":latest", + ) print("ROLLBACK VERIFIED", flush=True) def secret_config(self): @@ -392,7 +398,9 @@ class Deploy: command += ["--build-arg", key + "=" + value] self.run(*command, str(ROOT)) service = config["services"]["pigway-frame-flow"] - service["image"] = image + latest = self.env["PROD_IMAGE_REPO"] + ":latest" + self.run("image", "tag", image, latest) + service["image"] = latest proposed = self.secret_config() service["environment"].update(proposed) for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"): @@ -666,6 +674,7 @@ class Deploy: self.state["status"] = "success" self.state_io(self.state) print("BOOTSTRAP VERIFIED", flush=True) + self.cleanup_images() except BaseException: print("BOOTSTRAP FAILED", flush=True) self.diagnostics() @@ -726,9 +735,9 @@ class Deploy: print("Scoped container logs (redacted):", text, flush=True) def cleanup_images(self): - """Retain container references and the verified deployment's rollback baseline.""" + """Keep only latest after verification; protect unrelated container images.""" try: - protected = {self.state["previous_image"]} + protected = set() referenced_tags = set() container_ids = self.run("ps", "-aq").stdout.decode().split() if container_ids: @@ -758,22 +767,23 @@ class Deploy: if not all( re.fullmatch( re.escape(repo) - + r":(?:release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})", + + r":(?:latest|release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})", tag, ) for tag in tags ): continue - if image["Id"] == self.state["previous_image"]: - pin = self.state.get("rollback_tag") - # Untag duplicates only after the rollback alias is present. - if pin not in tags: + latest = repo + ":latest" + if image["Id"] in protected: + # Current production uses latest. Other containers retain + # their referenced tags and images, even when stopped. + if latest not in tags: continue tags = [ - tag for tag in tags if tag != pin and tag not in referenced_tags + tag + for tag in tags + if tag != latest and tag not in referenced_tags ] - elif image["Id"] in protected: - continue if not tags: continue result = self.run("image", "rm", *tags, check=False) diff --git a/tests/test_production_deploy.py b/tests/test_production_deploy.py index af5c548..e5ccbc5 100644 --- a/tests/test_production_deploy.py +++ b/tests/test_production_deploy.py @@ -253,13 +253,13 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch): tool.deploy() -def test_image_retention_protects_containers_rollback_and_other_tags(tool, monkeypatch): +def test_image_retention_keeps_latest_and_protects_other_containers(tool, monkeypatch): tool.state = {"previous_image": "previous"} release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 rollback = "pigway-frame-flow:rollback-" + "c" * 32 tool.state["rollback_tag"] = release + "-rollback" images = [ - {"Id": "current", "RepoTags": [release]}, + {"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]}, {"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]}, {"Id": "stopped", "RepoTags": [release]}, {"Id": "obsolete", "RepoTags": [release, rollback]}, @@ -273,7 +273,15 @@ def test_image_retention_protects_containers_rollback_and_other_tags(tool, monke if args == ("ps", "-aq"): data = "container1 container2" elif args[0] == "inspect": - data = json.dumps([{"Image": "current"}, {"Image": "stopped"}]) + data = json.dumps( + [ + { + "Image": "current", + "Config": {"Image": "pigway-frame-flow:latest"}, + }, + {"Image": "stopped"}, + ] + ) elif args[:2] == ("image", "ls"): data = "current previous stopped obsolete shared ci" elif args[:2] == ("image", "inspect"): @@ -285,7 +293,8 @@ def test_image_retention_protects_containers_rollback_and_other_tags(tool, monke monkeypatch.setattr(tool, "run", run) tool.cleanup_images() assert [c for c in calls if c[:2] == ("image", "rm")] == [ - ("image", "rm", release, rollback), + ("image", "rm", release), + ("image", "rm", release + "-rollback", release, rollback), ("image", "rm", release, rollback), ]