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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
110 changes: 110 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Queue every version-changing deployment

With the workflow-level concurrency configured this way, cancel-in-progress: false protects only the running workflow; GitHub still cancels and replaces an existing pending run when another run joins the group (GitHub documentation). If an older deployment is running, a version-bump push is pending, and a subsequent package.json change keeps that new version unchanged, the latter run replaces the bump run and then release-gate.py emits release=false because its immediate parent already contains that version. The bumped version is therefore never deployed automatically; retain the full queue or make the gate decide against the last published/deployed version rather than only github.event.before.

Useful? React with 👍 / 👎.


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"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Build output
build/
__pycache__/

# Compiled CSS (generated from SCSS)
css/style.css
Expand All @@ -15,4 +16,4 @@ firebase-debug.log
.DS_Store
Thumbs.db

.claude/
.claude/
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<commit>`. 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.
70 changes: 70 additions & 0 deletions scripts/install-release.py
Original file line number Diff line number Diff line change
@@ -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()
55 changes: 55 additions & 0 deletions scripts/release-gate.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 4 additions & 2 deletions scripts/stamp-version.js
Original file line number Diff line number Diff line change
@@ -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');
84 changes: 84 additions & 0 deletions scripts/tests/test_install_release.py
Original file line number Diff line number Diff line change
@@ -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": "<html>Website</html>",
"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()
Loading
Loading