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)
|