Repository navigation
Automate versioned website releases and deployment #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the workflow-level concurrency configured this way,
cancel-in-progress: falseprotects 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 subsequentpackage.jsonchange keeps that new version unchanged, the latter run replaces the bump run and thenrelease-gate.pyemitsrelease=falsebecause 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 onlygithub.event.before.Useful? React with 👍 / 👎.