diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95d2e2..05c5dae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,3 +82,10 @@ jobs: target_commitish: ${{ github.sha }} generate_release_notes: true files: image.txt + - name: Remove superseded initial container + if: needs.version.outputs.version == '0.0.1' + env: + VERSION: ${{ needs.version.outputs.version }} + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python tools/remove_superseded_release.py diff --git a/deploy/README.md b/deploy/README.md index 7a442e5..a5866f1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -82,6 +82,12 @@ extended outage can invalidate a slot and require a controlled resync. ## Updating and recovery +The initial testing baseline is `0.0.1`. Its release workflow removes the unused +`1.0.0` container by its exact manifest digest after publishing the replacement. +It preserves shared manifests and refuses to delete versions with unrelated tags. +The package itself stays public. This cleanup uses the publishing repository's +package admin access through `GITHUB_TOKEN` and only runs for `0.0.1`. + Deploy the backward-compatible server first, then roll out the client. Update `PAPYRUS_VERSION`, check out the corresponding source/config and install the web artifact before running `./deploy.sh`. Check Alembic current/head before/after a diff --git a/deploy/production.env.example b/deploy/production.env.example index cd9e406..38b1188 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -1,4 +1,4 @@ -PAPYRUS_VERSION=1.0.0 +PAPYRUS_VERSION=0.0.1 API_DOMAIN=api.papyrus-reader.com SYNC_DOMAIN=sync.papyrus-reader.com APP_DOMAIN=app.papyrus-reader.com diff --git a/pyproject.toml b/pyproject.toml index ecce2b8..d0212c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papyrus-server" -version = "1.0.0" +version = "0.0.1" description = "REST API server for Papyrus, a cross platform book management application." requires-python = ">=3.12" license = { text = "AGPL-3.0" } diff --git a/tools/release_gate.py b/tools/release_gate.py index 037abf3..53771ea 100644 --- a/tools/release_gate.py +++ b/tools/release_gate.py @@ -26,9 +26,11 @@ def parse(text: str, component: str) -> tuple[str, int]: return version, 0 -def decide(current: tuple[str, int], previous: tuple[str, int], component: str) -> bool: +def decide(current: tuple[str, int], previous: tuple[str, int], component: str, *, published: bool = True) -> bool: if current == previous: return False + if not published: + return True if tuple(map(int, current[0].split("."))) < tuple(map(int, previous[0].split("."))): raise ValueError("Release version cannot decrease") if component == "client" and current[1] <= previous[1]: @@ -78,7 +80,10 @@ def main() -> None: if args.manual: release = True elif args.base and set(args.base) != {"0"}: - release = decide(current, parse(git("show", f"{args.base}:{path}"), args.component), args.component) + published = bool(git("tag", "--list", "v[0-9]*")) + release = decide( + current, parse(git("show", f"{args.base}:{path}"), args.component), args.component, published=published + ) else: raise ValueError("A previous commit is required; use a manual build to bootstrap") version, number = current diff --git a/tools/remove_superseded_release.py b/tools/remove_superseded_release.py new file mode 100644 index 0000000..9f97360 --- /dev/null +++ b/tools/remove_superseded_release.py @@ -0,0 +1,133 @@ +"""Remove the unused 1.0.0 container after publishing the initial 0.0.1 release.""" + +import json +import os +import re +from typing import Any +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +LEGACY_DIGEST = "sha256:7da70ea17d60b5b0c71f7ea7743b9cb3e3256be1bc1d095bf009811be9749912" +LEGACY_SHA_TAG = "sha-c25ba084db02203db5835e0c424cc6bed4fe804b" +PACKAGE_API = "https://api.github.com/orgs/PapyrusReader/packages/container/server/versions" +MANIFEST_API = "https://ghcr.io/v2/papyrusreader/server/manifests" + + +def request_json(url: str, headers: dict[str, str], method: str = "GET") -> Any: + with urlopen(Request(url, headers=headers, method=method), timeout=30) as response: + data = response.read() + + return json.loads(data) if data else None + + +def manifest_graph(digest: str, headers: dict[str, str], *, missing_children: bool = False) -> set[str]: + """Include architecture and attestation manifests, without touching layers.""" + visited: set[str] = set() + pending = [digest] + + while pending: + current = pending.pop() + + if current in visited: + continue + + visited.add(current) + + try: + manifest = request_json(f"{MANIFEST_API}/{current}", headers) + except HTTPError as error: + if error.code == 404 and missing_children and current != digest: + continue + + raise + + pending.extend(item["digest"] for item in manifest.get("manifests", [])) + + return visited + + +def select_versions(versions: list[dict[str, Any]], legacy: set[str], replacement: set[str]) -> list[dict[str, Any]]: + """Protect shared manifests and refuse to remove unrelated tags.""" + if LEGACY_DIGEST in replacement: + raise ValueError("The replacement must be a different image") + + selected = [] + + for version in versions: + digest = version["name"] + + if digest not in legacy or digest in replacement: + continue + + tags = set(version.get("metadata", {}).get("container", {}).get("tags", [])) + + if tags - {"1.0.0", LEGACY_SHA_TAG}: + raise ValueError(f"Refusing to delete manifest with unrelated tags: {digest}") + + selected.append(version) + + return sorted(selected, key=lambda item: item["name"] == LEGACY_DIGEST) + + +def main() -> None: + if os.environ.get("VERSION") != "0.0.1" or os.environ.get("GITHUB_REPOSITORY") != "PapyrusReader/server": + raise ValueError("Cleanup is restricted to the initial Papyrus server 0.0.1 release") + + replacement_digest = os.environ["IMAGE_DIGEST"] + + if not re.fullmatch(r"sha256:[0-9a-f]{64}", replacement_digest) or replacement_digest == LEGACY_DIGEST: + raise ValueError("A valid replacement image digest is required") + + token = request_json("https://ghcr.io/token?service=ghcr.io&scope=repository:papyrusreader/server:pull", {})[ + "token" + ] + registry_headers = { + "Authorization": f"Bearer {token}", + "Accept": ", ".join( + ( + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", + ) + ), + } + replacement = manifest_graph(replacement_digest, registry_headers) + + try: + legacy = manifest_graph(LEGACY_DIGEST, registry_headers, missing_children=True) + except HTTPError as error: + if error.code != 404: + raise + + print("The obsolete image is already absent; no package versions deleted.") + return + + api_headers = { + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + versions = [] + page = 1 + + while True: + batch = request_json(f"{PACKAGE_API}?state=active&per_page=100&page={page}", api_headers) + versions.extend(batch) + + if len(batch) < 100: + break + + page += 1 + + selected = select_versions(versions, legacy, replacement) + + for version in selected: + request_json(f"{PACKAGE_API}/{version['id']}", api_headers, "DELETE") + print(f"Deleted obsolete manifest {version['name']}") + + print(f"Removed {len(selected)} obsolete package versions; preserved the replacement and public package.") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_release.py b/tools/tests/test_release.py index ee9757e..af7d403 100644 --- a/tools/tests/test_release.py +++ b/tools/tests/test_release.py @@ -35,6 +35,14 @@ def test_server_dependency_edit_does_not_release(self) -> None: with self.assertRaises(ValueError): gate.decide(("0.9.0", 0), first, "server") + def test_initial_version_can_be_reset_before_any_release(self) -> None: + for component, number in (("client", 1), ("server", 0)): + with self.subTest(component=component): + self.assertTrue(gate.decide(("0.0.1", number), ("1.0.0", number), component, published=False)) + self.assertFalse(gate.decide(("0.0.1", number), ("0.0.1", number), component, published=False)) + with self.assertRaises(ValueError): + gate.decide(("0.0.1", number), ("1.0.0", number), component, published=True) + def test_invalid_manifest_versions_are_rejected(self) -> None: for version in ("1.0.0", "1.0.0+0", "1.0.0+2100000001", "latest"): with self.assertRaises(ValueError): @@ -114,6 +122,18 @@ def test_compares_committed_values_and_skips_dependency_edits(self) -> None: self.assertIn("tag=v1.0.0+2", result.stdout) self.assertIn("release=true", result.stdout) + def test_committed_initial_reset_requires_no_release_tags(self) -> None: + self.manifest.write_text("version: 0.0.1+1\n") + self.commit() + result = self.run_gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("tag=v0.0.1+1", result.stdout) + self.assertIn("release=true", result.stdout) + self.command("tag", "v1.0.0+1", self.base) + result = self.run_gate("--base", self.base) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Release version cannot decrease", result.stderr) + def test_released_tag_cannot_be_reused_for_another_commit(self) -> None: self.command("tag", "v1.0.0+1") self.manifest.write_text("version: 1.0.0+1\ndependencies: changed\n") diff --git a/tools/tests/test_remove_superseded_release.py b/tools/tests/test_remove_superseded_release.py new file mode 100644 index 0000000..92f9fff --- /dev/null +++ b/tools/tests/test_remove_superseded_release.py @@ -0,0 +1,107 @@ +"""Protect unrelated package versions during the one-time release cleanup.""" + +import importlib.util +import unittest +from email.message import Message +from pathlib import Path +from typing import Any +from unittest.mock import patch +from urllib.error import HTTPError + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "remove_superseded_release.py" +spec = importlib.util.spec_from_file_location("cleanup", SCRIPT_PATH) +assert spec is not None and spec.loader is not None +cleanup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cleanup) + + +def version(identifier: int, digest: str, *tags: str) -> dict[str, Any]: + return {"id": identifier, "name": digest, "metadata": {"container": {"tags": list(tags)}}} + + +class CleanupTest(unittest.TestCase): + def test_protects_replacement_shared_and_unrelated_manifests(self) -> None: + root = version(1, cleanup.LEGACY_DIGEST, "1.0.0", cleanup.LEGACY_SHA_TAG) + child = version(2, "old-child") + versions = [root, child, version(3, "shared"), version(4, "new", "0.0.1"), version(5, "unrelated")] + selected = cleanup.select_versions(versions, {cleanup.LEGACY_DIGEST, "old-child", "shared"}, {"new", "shared"}) + self.assertEqual(selected, [child, root]) + + def test_refuses_to_delete_an_image_with_an_unrelated_tag(self) -> None: + versions = [version(1, cleanup.LEGACY_DIGEST, "1.0.0"), version(2, "child", "0.0.2")] + + with self.assertRaisesRegex(ValueError, "unrelated tags"): + cleanup.select_versions(versions, {cleanup.LEGACY_DIGEST, "child"}, {"new"}) + + def test_replacement_cannot_be_the_obsolete_image(self) -> None: + with self.assertRaisesRegex(ValueError, "different image"): + cleanup.select_versions([], {cleanup.LEGACY_DIGEST}, {cleanup.LEGACY_DIGEST}) + + def test_can_retry_after_a_child_manifest_was_already_deleted(self) -> None: + def request(url: str, headers: dict[str, str]) -> dict[str, Any]: + if url.endswith("/root"): + return {"manifests": [{"digest": "removed"}, {"digest": "remaining"}]} + + if url.endswith("/removed"): + raise HTTPError(url, 404, "Not Found", Message(), None) + + return {} + + with patch.object(cleanup, "request_json", side_effect=request): + self.assertEqual( + cleanup.manifest_graph("root", {}, missing_children=True), {"root", "removed", "remaining"} + ) + + with self.assertRaises(HTTPError): + cleanup.manifest_graph("root", {}) + + def test_main_paginates_and_deletes_only_obsolete_versions_children_first(self) -> None: + replacement = "sha256:" + "a" * 64 + deleted = [] + legacy = {cleanup.LEGACY_DIGEST, "child"} + + def request(url: str, headers: dict[str, str], method: str = "GET") -> Any: + if "ghcr.io/token" in url: + return {"token": "test-only"} + + if method == "DELETE": + deleted.append(url.rsplit("/", 1)[1]) + return None + + if url.endswith("&page=1"): + return [version(1, cleanup.LEGACY_DIGEST, "1.0.0"), version(2, "child")] + [ + version(identifier, f"other-{identifier}") for identifier in range(3, 101) + ] + + return [version(101, replacement, "0.0.1")] + + environ = { + "VERSION": "0.0.1", + "GITHUB_REPOSITORY": "PapyrusReader/server", + "IMAGE_DIGEST": replacement, + "GITHUB_TOKEN": "test-only", + } + + with ( + patch.dict("os.environ", environ, clear=True), + patch.object(cleanup, "manifest_graph", side_effect=[{replacement}, legacy]), + patch.object(cleanup, "request_json", side_effect=request), + patch("builtins.print"), + ): + cleanup.main() + + self.assertEqual(deleted, ["2", "1"]) + + def test_no_cleanup_for_another_release(self) -> None: + with ( + patch.dict("os.environ", {"VERSION": "0.0.2"}, clear=True), + patch.object(cleanup, "request_json") as request, + ): + with self.assertRaises(ValueError): + cleanup.main() + + request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index af82d71..7dbb896 100644 --- a/uv.lock +++ b/uv.lock @@ -789,7 +789,7 @@ wheels = [ [[package]] name = "papyrus-server" -version = "1.0.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },