From 269cd7012c7673803f63fba54d1ed195c291899e Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 4 Oct 2026 23:07:32 +0300 Subject: [PATCH 1/2] feat: coordinate client and server release versions --- .github/workflows/releases.yml | 25 ++++++++ DEVELOPMENT.md | 7 +++ RELEASING.md | 62 ++++++++++++++++++++ client | 2 +- release.json | 4 ++ server | 2 +- tools/release.py | 101 +++++++++++++++++++++++++++++++++ tools/tests/test_release.py | 96 +++++++++++++++++++++++++++++++ 8 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/releases.yml create mode 100644 RELEASING.md create mode 100644 release.json create mode 100644 tools/release.py create mode 100644 tools/tests/test_release.py diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml new file mode 100644 index 0000000..7b01738 --- /dev/null +++ b/.github/workflows/releases.yml @@ -0,0 +1,25 @@ +name: Coordinated release versions + +on: + push: + branches: [master] + paths: ['release.json', 'client', 'server', 'tools/release.py', '.github/workflows/releases.yml'] + pull_request: + paths: ['release.json', 'client', 'server', 'tools/release.py', '.github/workflows/releases.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + versions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python tools/release.py check --committed + - run: python -m unittest discover -s tools/tests -p 'test_release*.py' -v diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e544644..e394127 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -130,3 +130,10 @@ The layout follows [Codex skills](https://learn.chatgpt.com/docs/build-skills), and [Dart MCP setup](https://docs.flutter.dev/ai/get-started). FVM's [project configuration](https://fvm.app/documentation/getting-started/configuration) keeps SDK selection separate from the global toolchain. + +## Coordinated releases + +See [RELEASING.md](RELEASING.md) for shared client/server version metadata, +Android build numbers, first Google Play internal testing and the production +server deployment runbook. `python3 tools/release.py check` validates the local +release snapshot; CI uses `--committed` to check the recorded submodule revisions. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..d629f09 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,62 @@ +# Coordinated Papyrus releases + +The workspace `release.json` records a release snapshot: a shared semantic client +and server version, plus the Android upload number. Component repositories remain +independent. Matching versions identify a release; they do not mean an old client +must stop working when the server advances. Keep `/v1` backwards compatible and +deploy additive server changes before distributing a client that needs them. + +For the initial internal test, the coordinated version is `1.0.0`, Android build +`1`. The server package's old `0.1.0` metadata is corrected to match its API and +client. No reader version or reader implementation is changed by this policy. + +## Prepare a release + +From the workspace root, on clean release branches: + +```sh +python3 tools/release.py check +python3 tools/release.py bump 1.0.1 --android-build 2 +python3 tools/release.py check +``` + +The bump changes the client pubspec, server pyproject and only the server's own +package entry in `uv.lock`; it does not upgrade dependencies. Review the diffs, +then run `uv sync --locked --extra dev` in the server to refresh installed package +metadata. For a client-only rebuild (new endpoint settings, signing retry after +an uploaded build, etc.), keep the semantic number and increase `--android-build`. +The unchanged server version skips its release build. + +1. Create component PRs and run checks appropriate to the changed behavior. +2. Merge the server PR first if both versions changed. Its workflow publishes the + versioned GHCR image; deploy and verify it before rolling out the client. +3. Merge the client PR. Its workflow compares the actual committed version with + the previous push and builds signed Android AAB/web/Linux/Windows artifacts. +4. Update the workspace component pointers to the reviewed commits and commit + `release.json`. The workspace release CI checks **recorded gitlinks**, not a + contributor's dirty working copies. A coordinated workspace PR becomes valid + when its recorded client/server versions agree with the release snapshot. + +These are independent Git histories, so there is no atomic multi-repo merge. +The workspace validates the final snapshot; it does not automatically deploy a +new server or publish a Play release. Dependency-only edits to a manifest do not +release. Manual workflow dispatch on `master` bootstraps the first build or retries +an unreleased commit. Existing released tags cannot be reassigned to a different +commit. Android codes are committed and monotonic, never workflow counters. + +## First-device-test setup + +- Follow [client release/signing and Play setup](client/docs/RELEASING.md). +- Follow [server deployment and backup setup](server/deploy/README.md). +- Choose a domain you control and reserve `api.`, `sync.` and + `app.` for the API, PowerSync and the verification/reset web app. +- Build configuration belongs in the client GitHub `release` environment; server + deployment secrets stay on the VM. No live infrastructure is provisioned by CI. +- Internal testing is the initial target. Play account/app creation, upload key, + public endpoints and a first manual AAB upload are external prerequisites. + Account deletion/store declarations remain prerequisites for wider distribution. + +Use focused tests for the feature being released. Release-gate tests are fast +stdlib tests without Flutter or a database; native packaging gets an actual +Android build and page-size check. Full application suites remain part of existing +component PR CI, not something to rerun locally for every version-only edit. diff --git a/client b/client index 78b8240..3ae6c05 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 78b8240003f64c264ce420fddea7a3fdb606450c +Subproject commit 3ae6c05756cc6dcbcf31f292b929f4d089aaf3ff diff --git a/release.json b/release.json new file mode 100644 index 0000000..dd43912 --- /dev/null +++ b/release.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.0", + "android_build_number": 1 +} diff --git a/server b/server index f41e43e..60aeaa9 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit f41e43e482eb5e3afb2ca78c05869a3b46a46c7c +Subproject commit 60aeaa9b4b0f58b9ec04ca28e359e0e7a8564e0c diff --git a/tools/release.py b/tools/release.py new file mode 100644 index 0000000..f3915cc --- /dev/null +++ b/tools/release.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Check and update the release snapshot across independent Papyrus repositories.""" + +import argparse +import json +import re +import subprocess +import tomllib +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +def version_tuple(value: str) -> tuple[int, ...]: + if not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", value): + raise ValueError("Version must be MAJOR.MINOR.PATCH") + return tuple(map(int, value.split("."))) + + +def read(root: Path, path: str, committed: bool = False) -> str: + if not committed: + return (root / path).read_text() + component, relative = path.split("/", 1) if "/" in path else ("", path) + if component in ("client", "server"): + tree = subprocess.check_output(["git", "-C", str(root), "ls-tree", "HEAD", component], text=True) + revision = tree.split()[2] + return subprocess.check_output( + ["git", "-C", str(root / component), "show", f"{revision}:{relative}"], text=True + ) + return subprocess.check_output(["git", "-C", str(root), "show", f"HEAD:{path}"], text=True) + + +def state(root: Path, committed: bool = False) -> tuple[dict[str, Any], str, str, str]: + manifest = json.loads(read(root, "release.json", committed)) + version_tuple(manifest["version"]) + client = read(root, "client/app/pubspec.yaml", committed) + server = read(root, "server/pyproject.toml", committed) + lock = read(root, "server/uv.lock", committed) + match = re.search(r"^version: ([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)\s*$", client, re.M) + if not match: + raise ValueError("Invalid client version") + expected = manifest["version"] + if match[1] != expected or int(match[2]) != manifest["android_build_number"]: + raise ValueError("Client version/build number differs from release.json") + if not 0 < manifest["android_build_number"] <= 2100000000: + raise ValueError("Android build number is out of range") + if tomllib.loads(server)["project"]["version"] != expected: + raise ValueError("Server package version differs from release.json") + locked = next(p for p in tomllib.loads(lock)["package"] if p["name"] == "papyrus-server") + if locked["version"] != expected: + raise ValueError("Server lockfile version differs from release.json") + return manifest, client, server, lock + + +def bump(root: Path, version: str, number: int) -> None: + manifest, client, server, lock = state(root) + if version_tuple(version) < version_tuple(manifest["version"]): + raise ValueError("Version cannot decrease") + if not manifest["android_build_number"] < number <= 2100000000: + raise ValueError("Android build number must increase and remain within the Play limit") + old = manifest["version"] + replacements = { + "client/app/pubspec.yaml": re.sub( + r"^version: .*?$", f"version: {version}+{number}", client, count=1, flags=re.M + ), + "server/pyproject.toml": server.replace(f'version = "{old}"', f'version = "{version}"', 1), + "server/uv.lock": lock.replace( + f'name = "papyrus-server"\nversion = "{old}"', f'name = "papyrus-server"\nversion = "{version}"', 1 + ), + "release.json": json.dumps({"version": version, "android_build_number": number}, indent=2) + "\n", + } + if lock != replacements["server/uv.lock"] or version == old: + for path, contents in replacements.items(): + (root / path).write_text(contents) + else: + raise ValueError("Cannot locate the server package entry in uv.lock") + state(root) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + check = subparsers.add_parser("check") + check.add_argument("--committed", action="store_true") + update = subparsers.add_parser("bump") + update.add_argument("version") + update.add_argument("--android-build", required=True, type=int) + args = parser.parse_args() + if args.command == "bump": + bump(ROOT, args.version, args.android_build) + print( + "Updated client, server and workspace release intent. Commit component PRs first, then record their commits in the workspace." + ) + else: + manifest, *_ = state(ROOT, args.committed) + print(f"Coordinated release {manifest['version']}, Android build {manifest['android_build_number']}") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_release.py b/tools/tests/test_release.py new file mode 100644 index 0000000..1cc23ac --- /dev/null +++ b/tools/tests/test_release.py @@ -0,0 +1,96 @@ +"""Check coordinated version updates and fail before writing invalid bumps.""" + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("coordinated_release", Path(__file__).resolve().parents[1] / "release.py") +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class CoordinationTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + (self.root / "client/app").mkdir(parents=True) + (self.root / "server").mkdir() + (self.root / "release.json").write_text(json.dumps({"version": "1.0.0", "android_build_number": 1})) + (self.root / "client/app/pubspec.yaml").write_text("version: 1.0.0+1\ndependencies: unchanged\n") + (self.root / "server/pyproject.toml").write_text('[project]\nversion = "1.0.0"\n') + (self.root / "server/uv.lock").write_text( + '[[package]]\nname = "papyrus-server"\nversion = "1.0.0"\n[[package]]\nname = "other"\nversion = "1.0.0"\n' + ) + + def test_bump_updates_only_owned_version_metadata(self): + release.bump(self.root, "1.0.1", 2) + manifest, client, server, lock = release.state(self.root) + self.assertEqual(manifest, {"version": "1.0.1", "android_build_number": 2}) + self.assertIn("dependencies: unchanged", client) + self.assertIn('name = "other"\nversion = "1.0.0"', lock) + + def test_client_only_rebuild_keeps_server_version(self): + release.bump(self.root, "1.0.0", 2) + self.assertEqual(release.state(self.root)[0]["android_build_number"], 2) + + def test_rejected_bumps_do_not_write_any_files(self): + before = {path: path.read_bytes() for path in self.root.rglob("*") if path.is_file()} + for version, number in (("0.9.0", 2), ("1.0.1", 1), ("1.0.1", 2100000001), ("latest", 2)): + with self.assertRaises(ValueError): + release.bump(self.root, version, number) + self.assertEqual(before, {path: path.read_bytes() for path in before}) + + def test_drift_is_rejected(self): + (self.root / "server/pyproject.toml").write_text('[project]\nversion = "1.0.1"\n') + with self.assertRaisesRegex(ValueError, "Server package"): + release.state(self.root) + + def command(self, root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True, stderr=subprocess.DEVNULL).strip() + + def commit(self, root): + self.command(root, "add", ".") + self.command( + root, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ) + + def test_committed_check_uses_recorded_submodules(self): + for component in ("client", "server"): + directory = self.root / component + self.command(directory, "init", "-q") + self.commit(directory) + self.command(self.root, "init", "-q") + for component in ("client", "server"): + revision = self.command(self.root / component, "rev-parse", "HEAD") + self.command(self.root, "update-index", "--add", "--cacheinfo", "160000", revision, component) + self.command(self.root, "add", "release.json") + self.command( + self.root, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ) + (self.root / "server/pyproject.toml").write_text('[project]\nversion = "1.0.1"\n') + self.commit(self.root / "server") + release.state(self.root, committed=True) + with self.assertRaises(ValueError): + release.state(self.root) From 0ea2e4eb03d840db8c3b58f01873c5d842db9da1 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 4 Oct 2026 23:14:21 +0300 Subject: [PATCH 2/2] chore: record registered Papyrus release domain --- RELEASING.md | 6 ++++-- client | 2 +- server | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index d629f09..18d49d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,8 +48,10 @@ commit. Android codes are committed and monotonic, never workflow counters. - Follow [client release/signing and Play setup](client/docs/RELEASING.md). - Follow [server deployment and backup setup](server/deploy/README.md). -- Choose a domain you control and reserve `api.`, `sync.` and - `app.` for the API, PowerSync and the verification/reset web app. +- The registered domain is `papyrus-reader.com`. Use `api.papyrus-reader.com`, + `sync.papyrus-reader.com` and `app.papyrus-reader.com` for the API, PowerSync and + the verification/reset web app. Point these DNS records to the server after + selecting its public IP. - Build configuration belongs in the client GitHub `release` environment; server deployment secrets stay on the VM. No live infrastructure is provisioned by CI. - Internal testing is the initial target. Play account/app creation, upload key, diff --git a/client b/client index 3ae6c05..eeea522 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 3ae6c05756cc6dcbcf31f292b929f4d089aaf3ff +Subproject commit eeea5227a2c0d44bd30ee5606008040aa7000be7 diff --git a/server b/server index 60aeaa9..ba285df 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 60aeaa9b4b0f58b9ec04ca28e359e0e7a8564e0c +Subproject commit ba285dfe60011f5a0a4cc4f9ee88f7f391b85fad