oni.git

oni

~ A static Git browser written in Rust.
git clone https://github.com/debuman/oni.git
commit 94f3a107a9b52d31adacbb94a6e40553daf0e753
parent f03c5ada93bdeadbf0bab035b3a3a4c19d757704
Author: debuman <110329244+debuman@users.noreply.github.com>
Date: Thu, 12 Nov 2026 16:56:19 +0900

Deploy oni with GitHub Actions and Pages

Diffstat:
A.github/workflows/deploy.yml | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 39+++++++++++++++++++++++++++++++++++----
MSECURITY.md | 26++++++++++++++++++++------
Mscripts/build.py | 5++++-
Ascripts/package_site.py | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/test_package_site.py | 48++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 273 insertion(+), 11 deletion(-)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
@@ -0,0 +1,118 @@
+name: CI and deploy
+
+on:
+  pull_request:
+    branches: [main]
+  push:
+    branches: [main]
+  workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+  group: pages-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: false
+
+jobs:
+  build:
+    name: Test and build
+    runs-on: ubuntu-26.04
+    timeout-minutes: 30
+    permissions:
+      contents: read
+    env:
+      CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/cargo
+      GIT_TERMINAL_PROMPT: '0'
+    steps:
+      - name: Check out source
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
+        with:
+          python-version: '3.14'
+
+      - name: Install native build dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install --yes --no-install-recommends build-essential libmd4c-dev libmd4c-html0-dev libcurl4-openssl-dev libssl-dev zlib1g-dev util-linux
+          rustup toolchain install 1.99.1 --profile minimal --no-self-update
+          rustup default 1.99.1
+
+      - name: Restore patched Git
+        id: git-cache
+        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+        with:
+          path: .cache/secure-git/installed
+          key: git-ubuntu-26.04-${{ hashFiles('scripts/bootstrap_git.py') }}
+
+      - name: Build patched Git
+        if: steps.git-cache.outputs.cache-hit != 'true'
+        run: python scripts/bootstrap_git.py
+
+      - name: Select patched Git
+        run: |
+          echo "$GITHUB_WORKSPACE/.cache/secure-git/installed/bin" >> "$GITHUB_PATH"
+          .cache/secure-git/installed/bin/git --version
+
+      - name: Restore Cargo cache
+        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+        with:
+          path: |
+            ~/.cargo/registry
+            .cache/cargo
+          key: cargo-ubuntu-26.04-rust-1.99.1-${{ hashFiles('generator/Cargo.lock') }}
+
+      - name: Fetch locked dependencies
+        run: cargo fetch --locked --manifest-path generator/Cargo.toml
+
+      - name: Test Rust and build generator
+        run: |
+          cargo test --offline --locked --manifest-path generator/Cargo.toml
+          cargo build --release --offline --locked --manifest-path generator/Cargo.toml
+
+      - name: Run regression and security tests
+        run: |
+          python -m compileall -q scripts
+          python scripts/test_generator.py
+          python scripts/test_security.py
+          python scripts/test_package_site.py
+
+      - name: Build public repository browser
+        run: python scripts/build.py --base-url https://oni.sqzer.com
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+
+      - name: Verify generated site
+        run: python scripts/audit_site.py
+
+      - name: Package site including dot-prefixed repositories
+        run: python scripts/package_site.py --output "$RUNNER_TEMP/artifact.tar"
+
+      - name: Upload verified Pages artifact
+        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+        with:
+          name: github-pages
+          path: ${{ runner.temp }}/artifact.tar
+          retention-days: 1
+          if-no-files-found: error
+
+  deploy:
+    name: Deploy to GitHub Pages
+    if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+    needs: build
+    runs-on: ubuntu-26.04
+    timeout-minutes: 10
+    permissions:
+      pages: write
+      id-token: write
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - name: Deploy verified artifact
+        id: deployment
+        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
diff --git a/README.md b/README.md
@@ -1,6 +1,6 @@
 # oni
 
-A local static Git browser for `debuman`'s public repositories. The Rust generator,
+A static Git browser for `debuman`'s public repositories. The Rust generator,
 `sqzer-stagit`, produces the repository catalog, commit history, diffs, file views,
 branches, tags, rendered READMEs, and Atom feeds. It reimplements stagit's feature
 set without invoking the C stagit executable.
@@ -8,9 +8,8 @@ set without invoking the C stagit executable.
 This project maintains its own HTML templates and CSS. Local build artifacts and
 repository snapshots are kept in `.cache/`.
 
-The intended hostname is **oni.sqzer.com**. The source repository is
-[debuman/oni](https://github.com/debuman/oni). The site currently runs as a local
-preview; no deployment workflow is configured.
+The site is published at [oni.sqzer.com](https://oni.sqzer.com) through GitHub Pages.
+The source repository is [debuman/oni](https://github.com/debuman/oni).
 
 All project documentation, code comments, and docstrings are written in English.
 Mirrored repository content and Unicode test fixtures retain their original text.
@@ -45,6 +44,35 @@ Git. Builds select that installation and require Git 2.56.1 or newer.
 Feed URLs use the local preview address by default. The build script accepts
 `--base-url https://oni.sqzer.com` when generating files for that hostname.
 
+## CI and deployment
+
+[CI and deploy](https://github.com/debuman/oni/actions/workflows/deploy.yml) runs on
+pull requests to `main`, pushes to `main`, and manual workflow dispatches.
+
+- Every run tests Rust, generator behavior, security boundaries, and deployment
+  packaging, then builds all public repositories and checks the generated site.
+- A successful push to `main` deploys the verified artifact to GitHub Pages.
+  Manual runs on `main` also deploy. Pull requests never deploy.
+- Repository snapshots are fetched fresh. Changes made only in other repositories
+  appear on the next build; use **Run workflow** to refresh them without a source
+  change in this repository.
+- The build job has read-only repository permissions. Only the separate deployment
+  job can write to Pages and request an OIDC token. Action versions are pinned to
+  commit hashes, and checkout does not persist credentials.
+- The CI runner uses Ubuntu 26.04, Python 3.14, Rust 1.99.1, and the checksum-pinned
+  local Git build. Cargo dependencies and the patched Git installation are cached.
+
+GitHub Pages is configured for GitHub Actions and the custom domain `oni.sqzer.com`,
+with HTTPS enforced. The DNS CNAME points `oni.sqzer.com` to `debuman.github.io`.
+Generated HTML and repository archives are deployment artifacts, not source commits.
+The packager preserves dot-prefixed repositories such as `.github` while rejecting
+actual Git metadata directories, symlinks, and special files.
+
+The hosted site retains HTML sanitization, meta CSP, and referrer policy. GitHub
+Pages does not apply this project's custom HTTP response headers; see
+[SECURITY.md](SECURITY.md#hosting-requirements-and-remaining-boundaries) for that
+hosting boundary.
+
 ## Structure and dependencies
 
 - `generator/`: Rust generator with a locked Ammonia HTML5 sanitizer dependency.
@@ -56,6 +84,8 @@ Feed URLs use the local preview address by default. The build script accepts
 - `scripts/build.py`: GitHub API requests, bare repository synchronization, compilation, generation, and archives.
 - `scripts/readme_assets.py`: Relative README image and file link resolution.
 - `scripts/benchmark.py`: Scaling benchmarks and output equivalence checks.
+- `scripts/package_site.py`: Validated GitHub Pages artifact packaging.
+- `.github/workflows/deploy.yml`: CI checks and deployment to GitHub Pages.
 - `security/csp.txt`: Shared browser content policy for generated HTML and preview responses.
 - `SECURITY.md`: Trust boundaries, security review findings, limits, and deployment requirements.
 - `public/`: Generated site, including license notices under `licenses/`.
@@ -102,6 +132,7 @@ cargo test --offline --locked --manifest-path generator/Cargo.toml --target-dir
 cargo build --release --offline --locked --manifest-path generator/Cargo.toml --target-dir .cache/cargo
 python3 scripts/test_generator.py
 python3 scripts/test_security.py
+python3 scripts/test_package_site.py
 python3 scripts/audit_site.py
 ```
 
diff --git a/SECURITY.md b/SECURITY.md
@@ -90,12 +90,26 @@ curl, and the browser also need ongoing security updates.
 
 ## Hosting requirements and remaining boundaries
 
-The local Python server is a development preview. Before publishing oni.sqzer.com,
-configure the production host to return the policy in `security/csp.txt`, append
-`frame-ancestors 'none'`, and send `X-Content-Type-Options: nosniff`,
-`X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`. HTML meta CSP is retained
-as a fallback, but it cannot enforce frame-ancestors or MIME handling. Hosting is
-not configured or verified by this local review.
+The local Python server is a development preview. Production uses GitHub Pages,
+which does not apply the preview server's custom response headers. Generated HTML
+still contains the script-blocking policy in `security/csp.txt` and a no-referrer
+meta policy, and repository HTML is sanitized before deployment. Meta CSP cannot
+enforce `frame-ancestors`, and HTML cannot set `X-Content-Type-Options` or
+`X-Frame-Options`. These HTTP-layer protections are not claimed for the direct
+GitHub Pages deployment.
+
+To enforce the full response policy, use a proxy or host that can set the CSP from
+`security/csp.txt` plus `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`,
+`X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`. Changing DNS alone does
+not add these headers. The DNS configuration and HTTPS certificate belong to the
+GitHub Pages deployment; no Cloudflare proxy rules are managed by this repository.
+
+CI validates pull requests with read-only repository permissions. Deployment runs
+only after a successful build on `main`, in a separate job with Pages/OIDC access.
+Checkout credentials are not persisted, actions are pinned, and build artifacts
+are checked for unsafe files and broken links before upload. The Pages packager
+retains the public `.github` repository but refuses `.git` metadata, symlinks, and
+special files. No deployment credentials are passed to repository content.
 
 External HTTPS images remain permitted and can disclose a visitor's IP address to
 their host. Outbound links and downloaded source archives contain repository data;
diff --git a/scripts/build.py b/scripts/build.py
@@ -43,7 +43,10 @@ def run(*args, **kwargs):
 
 
 def request(url):
-    req = Request(url, headers={"User-Agent": "oni.sqzer.com-local-preview"})
+    headers = {"User-Agent": "oni.sqzer.com", "Accept": "application/vnd.github+json"}
+    if url.startswith("https://api.github.com/") and (token := os.environ.get("GITHUB_TOKEN")):
+        headers["Authorization"] = "Bearer " + token
+    req = Request(url, headers=headers)
     with urlopen(req, timeout=90) as response:
         data = response.read(8 * 1024 * 1024 + 1)
     if len(data) > 8 * 1024 * 1024:
diff --git a/scripts/package_site.py b/scripts/package_site.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""Package only generated regular files, preserving dot-prefixed repository names."""
+
+import argparse
+from pathlib import Path
+import tarfile
+
+ROOT = Path(__file__).resolve().parents[1]
+MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024
+
+
+def package(public, output):
+    public = public.resolve()
+    output = output.resolve()
+    if output.is_relative_to(public):
+        raise ValueError("Place the deployment archive outside the generated site")
+    if not (public / "index.html").is_file():
+        raise ValueError("The generated site has no index.html")
+    paths = sorted(public.rglob("*"))
+    total = 0
+    for path in paths:
+        if path.is_symlink() or path.is_junction() or not (path.is_file() or path.is_dir()):
+            raise ValueError(f"Deployment contains a link or special file: {path}")
+        if ".git" in path.relative_to(public).parts:
+            raise ValueError("Git metadata must never be deployed")
+        if path.is_file():
+            total += path.stat().st_size
+        total += 2048
+    if total >= MAX_ARCHIVE_BYTES:
+        raise ValueError("Generated site exceeds the 1 GiB deployment limit")
+    output.parent.mkdir(parents=True, exist_ok=True)
+    with tarfile.open(output, "w", format=tarfile.PAX_FORMAT, dereference=True) as archive:
+        for path in paths:
+            archive.add(path, arcname=path.relative_to(public).as_posix(), recursive=False)
+    if output.stat().st_size >= MAX_ARCHIVE_BYTES:
+        raise ValueError("Deployment archive exceeds the 1 GiB limit")
+    print(f"Packaged {len(paths)} entries into {output} ({output.stat().st_size:,} bytes)")
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--output", type=Path, required=True)
+    args = parser.parse_args()
+    package(ROOT / "public", args.output)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/test_package_site.py b/scripts/test_package_site.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""Verify deployment packaging preserves repository paths without leaking metadata."""
+
+from pathlib import Path
+import tarfile
+import tempfile
+import unittest
+
+from package_site import package
+
+
+class PackageTests(unittest.TestCase):
+    def test_dot_prefixed_repositories_and_paths_are_preserved(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            public = root / "public"
+            (public / ".github/file").mkdir(parents=True)
+            (public / "index.html").write_text("catalog")
+            (public / ".github/index.html").write_text("repository")
+            (public / ".github/file/.gitignore.html").write_text("source view")
+            output = root / "artifact.tar"
+            package(public, output)
+            with tarfile.open(output) as archive:
+                self.assertIn(".github/index.html", archive.getnames())
+                self.assertIn(".github/file/.gitignore.html", archive.getnames())
+                self.assertTrue(all(member.isfile() or member.isdir() for member in archive))
+            with self.assertRaises(ValueError):
+                package(public, public / "artifact.tar")
+            (public / ".git").mkdir()
+            (public / ".git/config").write_text("private metadata")
+            with self.assertRaises(ValueError):
+                package(public, output)
+
+    def test_links_are_rejected(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            public = root / "public"
+            public.mkdir()
+            (public / "index.html").write_text("catalog")
+            outside = root / "private.txt"
+            outside.write_text("private")
+            (public / "linked.txt").symlink_to(outside)
+            with self.assertRaises(ValueError):
+                package(public, root / "artifact.tar")
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)