diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 0104ba7..a80943b 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -11,7 +11,41 @@ permissions: contents: read jobs: + update-asset-version: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Update asset version + run: python script/asset_version.py + + - name: Commit asset version if changed + run: | + if git diff --quiet -- _data/assets.yml; then + echo "Asset version already up to date" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add _data/assets.yml + git commit -m "Update asset version hash for CSS/JS changes." + git push + build: + needs: update-asset-version + if: always() && (needs.update-asset-version.result == 'success' || needs.update-asset-version.result == 'skipped') runs-on: ubuntu-latest steps: - name: Checkout @@ -30,8 +64,9 @@ jobs: - name: Check WebP derivatives run: python script/generate_webp.py --check - - name: Generate asset version - run: python script/asset_version.py + - name: Check asset version + if: github.event_name != 'pull_request' + run: python script/asset_version.py --check - name: Setup Pages uses: actions/configure-pages@v5 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index d7f5ac8..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_data/assets.yml diff --git a/README.md b/README.md index 017a867..ff5dd67 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,13 @@ CI runs `python script/generate_webp.py --check` to ensure WebP copies are prese ### CSS/JS cache busting -CI runs `python script/asset_version.py` before each Jekyll build. It hashes `css/style.css`, `css/github_markdown.css`, and `js/gallery.js`, then writes `_data/assets.yml`. Layout templates append `?v=` that hash to stylesheet and script URLs so browsers fetch new copies when those files change. +`_data/assets.yml` stores a content hash for `css/style.css`, `css/github_markdown.css`, and `js/gallery.js`. Layout templates append it as `?v=` on stylesheet and script URLs. The file must be in the repo because GitHub Pages runs its own Jekyll build. -For a local Jekyll preview, run the same script first: +On pull requests, CI runs `script/asset_version.py` and commits an updated `_data/assets.yml` to the PR branch when CSS or JS changed, so the hash is ready before you merge. + +Pushes to `master` run `script/asset_version.py --check` instead (the merged PR should already include the updated file). + +To update locally: ```bash python script/asset_version.py diff --git a/_data/assets.yml b/_data/assets.yml new file mode 100644 index 0000000..62d4178 --- /dev/null +++ b/_data/assets.yml @@ -0,0 +1 @@ +assets_version: "8a29461d" diff --git a/_layouts/default.html b/_layouts/default.html index 711f509..224bb3b 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -44,7 +44,7 @@ - {% assign assets_version = site.data.assets.assets_version | default: 'dev' %} + {% assign assets_version = site.data.assets.assets_version | default: site.github.build_revision | default: 'dev' %} diff --git a/script/asset_version.py b/script/asset_version.py index 98b6e23..7c79bfa 100644 --- a/script/asset_version.py +++ b/script/asset_version.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Write _data/assets.yml with a content hash for CSS/JS cache busting.""" +"""Manage _data/assets.yml content hash for CSS/JS cache busting.""" from __future__ import annotations +import argparse import hashlib +import re import sys from pathlib import Path @@ -15,20 +17,60 @@ OUTPUT = Path("_data/assets.yml") -def main() -> int: +def compute_version() -> str: hasher = hashlib.sha256() for path in ASSET_FILES: if not path.is_file(): - print(f"Missing asset file: {path}", file=sys.stderr) - return 1 + raise FileNotFoundError(path) hasher.update(path.read_bytes()) hasher.update(b"\0") - version = hasher.hexdigest()[:8] + return hasher.hexdigest()[:8] + + +def read_version() -> str | None: + if not OUTPUT.is_file(): + return None + + match = re.search(r'^assets_version:\s*"([0-9a-f]+)"\s*$', OUTPUT.read_text(encoding="utf-8"), re.MULTILINE) + return match.group(1) if match else None + + +def write_version(version: str) -> None: OUTPUT.parent.mkdir(exist_ok=True) OUTPUT.write_text(f'assets_version: "{version}"\n', encoding="utf-8") - print(f"Wrote {OUTPUT} (assets_version: {version})") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Fail if _data/assets.yml is missing or does not match current CSS/JS files", + ) + args = parser.parse_args() + + try: + expected = compute_version() + except FileNotFoundError as exc: + print(f"Missing asset file: {exc}", file=sys.stderr) + return 1 + + if args.check: + actual = read_version() + if actual != expected: + print( + f"_data/assets.yml is out of date (have {actual!r}, expected {expected!r}). " + "Run: python script/asset_version.py", + file=sys.stderr, + ) + return 1 + print(f"Asset version OK ({expected})") + return 0 + + write_version(expected) + print(f"Wrote {OUTPUT} (assets_version: {expected})") return 0