Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deploy/production.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
9 changes: 7 additions & 2 deletions tools/release_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions tools/remove_superseded_release.py
Original file line number Diff line number Diff line change
@@ -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()
20 changes: 20 additions & 0 deletions tools/tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand Down
107 changes: 107 additions & 0 deletions tools/tests/test_remove_superseded_release.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading