diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1430d80..9ffb7dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,6 @@ jobs: with: node-version: '22' cache: npm + - run: python3 -m unittest discover -s scripts/tests -v - run: npm ci - run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..45a872f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +name: Website release + +on: + push: + branches: [master] + paths: [package.json] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: website-production + cancel-in-progress: false + +jobs: + version: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + outputs: + release: ${{ steps.gate.outputs.release }} + version: ${{ steps.gate.outputs.version }} + tag: ${{ steps.gate.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check website version + id: gate + env: + BEFORE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = workflow_dispatch ]; then + python3 scripts/release-gate.py --manual + else + python3 scripts/release-gate.py --base "$BEFORE" + fi + + release: + needs: version + if: needs.version.outputs.release == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + environment: + name: production + url: https://papyrus-reader.com + env: + BUILD_REVISION: ${{ github.sha }} + WEBSITE_VERSION: ${{ needs.version.outputs.version }} + WEBSITE_TAG: ${{ needs.version.outputs.tag }} + WEBSITE_SSH_HOST: ${{ vars.WEBSITE_SSH_HOST }} + WEBSITE_SSH_USER: ${{ vars.WEBSITE_SSH_USER }} + WEBSITE_SSH_KNOWN_HOSTS: ${{ vars.WEBSITE_SSH_KNOWN_HOSTS }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: python3 -m unittest discover -s scripts/tests -v + - run: npm ci + - run: npm run build + - name: Package website + run: | + tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner -czf "papyrus-website-v${WEBSITE_VERSION}.tar.gz" -C build . + sha256sum "papyrus-website-v${WEBSITE_VERSION}.tar.gz" > "papyrus-website-v${WEBSITE_VERSION}.tar.gz.sha256" + - uses: actions/upload-artifact@v4 + with: + name: website-release + path: papyrus-website-v${{ needs.version.outputs.version }}.tar.gz* + if-no-files-found: error + - name: Deploy website + env: + WEBSITE_SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }} + run: | + test -n "$WEBSITE_SSH_HOST" && test -n "$WEBSITE_SSH_USER" && test -n "$WEBSITE_SSH_KNOWN_HOSTS" && test -n "$WEBSITE_SSH_PRIVATE_KEY" || { echo "Configure the website production SSH settings"; exit 1; } + install -d -m 700 "$RUNNER_TEMP/website-ssh" + printf '%s\n' "$WEBSITE_SSH_PRIVATE_KEY" > "$RUNNER_TEMP/website-ssh/key" + chmod 600 "$RUNNER_TEMP/website-ssh/key" + printf '%s\n' "$WEBSITE_SSH_KNOWN_HOSTS" > "$RUNNER_TEMP/website-ssh/known_hosts" + target="${WEBSITE_SSH_USER}@${WEBSITE_SSH_HOST}" + options=(-i "$RUNNER_TEMP/website-ssh/key" -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$RUNNER_TEMP/website-ssh/known_hosts") + archive="papyrus-website-v${WEBSITE_VERSION}.tar.gz" + checksum=$(sha256sum "$archive" | cut -d ' ' -f 1) + scp "${options[@]}" "$archive" "$target:incoming.tar.gz" + printf -v remote_command 'python3 - incoming.tar.gz %q %q' "$GITHUB_SHA" "$checksum" + # shellcheck disable=SC2029 + ssh "${options[@]}" "$target" "$remote_command" < scripts/install-release.py + - name: Verify public website + run: | + curl --fail --silent --show-error --max-time 20 --retry 5 --retry-all-errors --retry-delay 2 \ + "https://papyrus-reader.com/version.json?deployment=${GITHUB_SHA}" > "$RUNNER_TEMP/website-version.json" + python3 -c 'import json, os; from pathlib import Path; metadata = json.loads((Path(os.environ["RUNNER_TEMP"]) / "website-version.json").read_text()); assert metadata["revision"] == os.environ["GITHUB_SHA"], "Public website does not match the release"' + - name: Publish website release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "$WEBSITE_TAG" >/dev/null 2>&1; then + gh release upload "$WEBSITE_TAG" "papyrus-website-v${WEBSITE_VERSION}.tar.gz" "papyrus-website-v${WEBSITE_VERSION}.tar.gz.sha256" --clobber + else + gh release create "$WEBSITE_TAG" "papyrus-website-v${WEBSITE_VERSION}.tar.gz" "papyrus-website-v${WEBSITE_VERSION}.tar.gz.sha256" \ + --target "$GITHUB_SHA" --title "Website ${WEBSITE_VERSION}" --generate-notes + fi + - name: Remove temporary SSH credentials + if: always() + run: rm -rf "$RUNNER_TEMP/website-ssh" diff --git a/.gitignore b/.gitignore index f090a0d..c316d56 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output build/ +__pycache__/ # Compiled CSS (generated from SCSS) css/style.css @@ -15,4 +16,4 @@ firebase-debug.log .DS_Store Thumbs.db -.claude/ \ No newline at end of file +.claude/ diff --git a/README.md b/README.md index 0becb40..d75bba2 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,39 @@ npm run dev This starts a local server at `http://localhost:8090` with live SCSS recompilation. Build deployable assets with `npm run build`; output is written to `build/`. + +## Releases and production deployment + +The public website is served by Caddy at `https://papyrus-reader.com`; `www` +redirects there. The Flutter app remains at `https://app.papyrus-reader.com`. +Website versions in `package.json` are independent of client/server versions. + +Run `npm version patch --no-git-tag-version` to update `package.json` and +`package-lock.json`, then include both files in the website pull request. +After merging into `master`, **Website release** builds only when that version +increases. It verifies and deploys the archive, checks the public build revision, +then publishes a GitHub release with its SHA-256 checksum. Run the workflow +manually from `master` for the initial release or to retry the same version. +A tag already belonging to a different commit requires a new website version. + +The repository's `production` GitHub environment needs: + +| Setting | Kind | Value | +| --- | --- | --- | +| `WEBSITE_SSH_HOST` | Variable | Hetzner server address | +| `WEBSITE_SSH_USER` | Variable | Dedicated `papyrus-website` user | +| `WEBSITE_SSH_KNOWN_HOSTS` | Variable | SSH host key verified through the existing administrator connection | +| `WEBSITE_SSH_PRIVATE_KEY` | Secret | Dedicated website upload key | + +Allow only `master` to deploy through that environment. The deployment account +owns `/srv/apps/papyrus/deploy/website` and has no sudo or Docker access. The +server's Caddy mounts that directory read-only. Each deployment validates the +archive checksum, revision and required assets before atomically switching the +relative `current` symlink to `releases/`. Previous releases remain +available for rollback; switch `current` back to an earlier relative release +path without restarting Caddy. Keep `website/` separate from the Flutter `web/` +directory and from server credentials. + +Run the focused automation checks with +`python3 -m unittest discover -s scripts/tests -v`. Normal website CI also builds +the static assets; it never deploys pull requests. diff --git a/scripts/install-release.py b/scripts/install-release.py new file mode 100644 index 0000000..af59506 --- /dev/null +++ b/scripts/install-release.py @@ -0,0 +1,70 @@ +"""Verify and atomically activate a static website release without restarting Caddy.""" + +import argparse +import fcntl +import hashlib +import json +import os +import re +import shutil +import tarfile +import tempfile +from pathlib import Path + + +def install(archive: Path, revision: str, checksum: str, root: Path) -> None: + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("Expected a Git commit SHA") + if not re.fullmatch(r"[0-9a-f]{64}", checksum): + raise ValueError("Expected a SHA-256 checksum") + if hashlib.sha256(archive.read_bytes()).hexdigest() != checksum: + raise ValueError("Website archive checksum mismatch") + releases = root / "releases" + releases.mkdir(parents=True, exist_ok=True) + with (root / ".deployment.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=releases)) + try: + with tarfile.open(archive, "r:gz") as source: + for member in source.getmembers(): + path = Path(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isfile() or member.isdir()): + raise ValueError("Website archive contains an unsafe entry") + source.extractall(staging, filter="data") + metadata = json.loads((staging / "version.json").read_text()) + if metadata["revision"] != revision: + raise ValueError("Website artifact revision does not match the build") + for required in ("index.html", "css/style.css", "js/main.js"): + if not (staging / required).is_file(): + raise ValueError(f"Website artifact is missing {required}") + (staging / ".artifact-sha256").write_text(checksum + "\n") + for path in [staging, *staging.rglob("*")]: + path.chmod(0o755 if path.is_dir() else 0o644) + release = releases / revision + if release.exists(): + if (release / ".artifact-sha256").read_text().strip() != checksum: + raise ValueError("An existing release has different contents") + else: + staging.rename(release) + link = root / ".current-next" + link.unlink(missing_ok=True) + link.symlink_to(f"releases/{revision}") + os.replace(link, root / "current") + print(f"Activated website commit {revision}") + finally: + if staging.exists(): + shutil.rmtree(staging) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("archive", type=Path) + parser.add_argument("revision") + parser.add_argument("checksum") + parser.add_argument("--root", type=Path, default=Path("/srv/apps/papyrus/deploy/website")) + args = parser.parse_args() + install(args.archive, args.revision, args.checksum, args.root) + + +if __name__ == "__main__": + main() diff --git a/scripts/release-gate.py b/scripts/release-gate.py new file mode 100644 index 0000000..3d71eb5 --- /dev/null +++ b/scripts/release-gate.py @@ -0,0 +1,55 @@ +"""Decide whether a website version should be built and deployed.""" + +import argparse +import json +import os +import re +import subprocess +from pathlib import Path + + +def version_parts(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("Website version must be MAJOR.MINOR.PATCH") + return tuple(map(int, value.split("."))) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--base") + parser.add_argument("--manual", action="store_true") + args = parser.parse_args() + version = json.loads(Path("package.json").read_text())["version"] + current = version_parts(version) + release = args.manual + if not args.manual: + if not args.base or not re.fullmatch(r"[0-9a-f]{40}", args.base): + raise ValueError("A push requires its previous commit SHA") + if args.base == "0" * 40: + release = True + else: + previous = json.loads(subprocess.check_output( + ["git", "show", f"{args.base}:package.json"], text=True + ))["version"] + old = version_parts(previous) + if current < old: + raise ValueError("Website version cannot decrease") + release = current > old + tag = f"v{version}" + existing = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], + capture_output=True, text=True, + ) + if release and existing.returncode == 0: + head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if existing.stdout.strip() != head: + raise ValueError("This website version belongs to another commit; bump the version") + output = f"version={version}\ntag={tag}\nrelease={str(release).lower()}\n" + if os.environ.get("GITHUB_OUTPUT"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as target: + target.write(output) + print(output, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/stamp-version.js b/scripts/stamp-version.js index 1f7d1d3..9275a31 100644 --- a/scripts/stamp-version.js +++ b/scripts/stamp-version.js @@ -1,8 +1,10 @@ const fs = require('fs'); const { version } = require('../package.json'); +const assetVersion = process.env.BUILD_REVISION || version; const file = 'build/index.html'; let html = fs.readFileSync(file, 'utf8'); -html = html.replace(/css\/style\.css/g, `css/style.css?v=${version}`); -html = html.replace(/js\/main\.js/g, `js/main.js?v=${version}`); +html = html.replace(/css\/style\.css/g, `css/style.css?v=${assetVersion}`); +html = html.replace(/js\/main\.js/g, `js/main.js?v=${assetVersion}`); fs.writeFileSync(file, html); +fs.writeFileSync('build/version.json', JSON.stringify({ version, revision: process.env.BUILD_REVISION || null }) + '\n'); diff --git a/scripts/tests/test_install_release.py b/scripts/tests/test_install_release.py new file mode 100644 index 0000000..3da8441 --- /dev/null +++ b/scripts/tests/test_install_release.py @@ -0,0 +1,84 @@ +"""Check release activation and rejection without touching a live website.""" + +import hashlib +import importlib.util +import io +import json +import tarfile +import tempfile +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location("installer", Path(__file__).parents[1] / "install-release.py") +installer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(installer) + + +class InstallReleaseTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.base = Path(self.directory.name) + self.root = self.base / "website" + self.revision = "a" * 40 + + def archive(self, revision=None, extra=None): + path = self.base / "website.tar.gz" + files = { + "index.html": "Website", + "css/style.css": "body {}", + "js/main.js": "console.log('website')", + "version.json": json.dumps({"version": "1.0.0", "revision": revision or self.revision}), + } + with tarfile.open(path, "w:gz") as target: + for name, value in files.items(): + entry = tarfile.TarInfo(name) + payload = value.encode() + entry.size = len(payload) + target.addfile(entry, io.BytesIO(payload)) + if extra: + target.addfile(extra) + return path, hashlib.sha256(path.read_bytes()).hexdigest() + + def test_activation_retains_previous_release_and_supports_retry(self): + archive, checksum = self.archive() + installer.install(archive, self.revision, checksum, self.root) + installer.install(archive, self.revision, checksum, self.root) + previous = (self.root / "current").resolve() + revision = "b" * 40 + archive, checksum = self.archive(revision) + installer.install(archive, revision, checksum, self.root) + self.assertEqual((self.root / "current").readlink(), Path("releases") / revision) + self.assertTrue((previous / "index.html").exists()) + self.assertEqual((self.root / "current/index.html").stat().st_mode & 0o777, 0o644) + + def test_bad_checksum_does_not_replace_current_site(self): + archive, checksum = self.archive() + installer.install(archive, self.revision, checksum, self.root) + previous = (self.root / "current").readlink() + with self.assertRaisesRegex(ValueError, "checksum"): + installer.install(archive, "b" * 40, "0" * 64, self.root) + self.assertEqual((self.root / "current").readlink(), previous) + + def test_wrong_revision_is_not_activated(self): + archive, checksum = self.archive() + with self.assertRaisesRegex(ValueError, "revision"): + installer.install(archive, "b" * 40, checksum, self.root) + self.assertFalse((self.root / "current").exists()) + + def test_traversal_and_symlinks_are_rejected(self): + traversal = tarfile.TarInfo("../outside") + symlink = tarfile.TarInfo("link") + symlink.type = tarfile.SYMTYPE + symlink.linkname = "/etc/passwd" + for entry in (traversal, symlink): + with self.subTest(entry=entry.name): + archive, checksum = self.archive(extra=entry) + with self.assertRaisesRegex(ValueError, "unsafe"): + installer.install(archive, self.revision, checksum, self.root) + self.assertFalse((self.base / "outside").exists()) + self.assertFalse((self.root / "current").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_release_gate.py b/scripts/tests/test_release_gate.py new file mode 100644 index 0000000..a3f7f1d --- /dev/null +++ b/scripts/tests/test_release_gate.py @@ -0,0 +1,64 @@ +"""Exercise version-triggered releases against a disposable Git repository.""" + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parents[1] / "release-gate.py" + + +class ReleaseGateTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.repo = Path(self.directory.name) + self.git("init", "--quiet") + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.com") + self.git("config", "commit.gpgsign", "false") + self.commit("1.0.0") + self.base = self.git("rev-parse", "HEAD").strip() + + def git(self, *args): + return subprocess.check_output(["git", *args], cwd=self.repo, text=True) + + def commit(self, version): + (self.repo / "package.json").write_text(json.dumps({"version": version})) + self.git("add", "package.json") + self.git("commit", "--quiet", "--allow-empty", "-m", "Update website") + + def gate(self, *args): + return subprocess.run(["python3", str(SCRIPT), *args], cwd=self.repo, capture_output=True, text=True) + + def test_unchanged_version_skips_and_increase_releases(self): + self.commit("1.0.0") + result = self.gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release=false", result.stdout) + self.commit("1.0.1") + result = self.gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release=true", result.stdout) + self.assertIn("tag=v1.0.1", result.stdout) + + def test_manual_retry_accepts_its_tag_but_rejects_reusing_another_commit(self): + self.git("tag", "v1.0.0") + result = self.gate("--manual") + self.assertEqual(result.returncode, 0, result.stderr) + self.commit("1.0.0") + result = self.gate("--manual") + self.assertNotEqual(result.returncode, 0) + self.assertIn("another commit", result.stderr) + + def test_decreasing_or_invalid_version_is_rejected(self): + for version in ("0.9.0", "01.0.0", "1.0.0-beta"): + with self.subTest(version=version): + self.commit(version) + result = self.gate("--base", self.base) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main()