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
25 changes: 25 additions & 0 deletions .github/workflows/releases.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
64 changes: 64 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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).
- 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,
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.
2 changes: 1 addition & 1 deletion client
4 changes: 4 additions & 0 deletions release.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"android_build_number": 1
}
101 changes: 101 additions & 0 deletions tools/release.py
Original file line number Diff line number Diff line change
@@ -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()
96 changes: 96 additions & 0 deletions tools/tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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)
Loading