diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de91110 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f842c37 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @deucebucket diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e56c416 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - dependencies diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..d7cbd0b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,57 @@ +name: Validate and deploy public dossier + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out the exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Run publication-boundary regressions + env: + PYTHONDONTWRITEBYTECODE: "1" + run: python3 -m unittest discover -s tests -p "test_*.py" -v + - name: Validate links, inventory, history, and publication boundary + env: + PYTHONDONTWRITEBYTECODE: "1" + run: python3 scripts/check_site.py + + deploy: + if: github.event_name != 'pull_request' + needs: validate + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Check out the exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Upload the static dossier + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + - name: Deploy + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..93d1ba0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +This is a curated public dossier, not a mirror of the development repository. + +1. Start from an already sanitized, public-safe milestone. +2. Rewrite it for readers; do not copy private notes or evidence packets. +3. Preserve the proof vocabulary: matched, verified, and snapshot-verified are + different claims. +4. Keep game-derived media and live payloads out of Git. +5. Run `python3 scripts/check_site.py`. +6. Open a pull request for review. + +Numbers on the dossier are dated snapshots. Do not silently turn them into a +live feed. diff --git a/README.md b/README.md index d973d92..c0e0ed9 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,23 @@ outside this public projection. This repository must remain safe to clone and publish in full: no game assets, ROM data, keys, raw evidence, private wiki copies, credentials, or live telemetry. -The themed dossier is being prepared on a review branch before any Pages -deployment. +## Public surfaces + +- Default dossier URL after merge: +- Eventual dossier URL: `https://decomp.deucebucket.com/infamous/` +- Proposed local-live URL: `https://live.decomp.deucebucket.com/infamous` + +The custom domain and proposed live hostname are deliberately **not cut over**. +The live link is labeled as a hold everywhere it appears. + +## Work locally + +```sh +python3 scripts/check_site.py +python3 -m http.server 8080 --directory site +``` + +Then open . + +Pull requests validate but do not deploy. A merge to `main` deploys only the +`site/` artifact through GitHub Pages. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8474132 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,29 @@ +# Dossier architecture + +`site/` is a hand-curated static artifact. It has no build-time connection to +private source, evidence stores, game data, or live telemetry. + +## Content map + +- `/infamous/` — current public snapshot and two-track overview +- `/infamous/devlog/` — dated, public-safe milestone notes +- `/infamous/method/` — proof vocabulary and clean-room boundaries +- `/infamous/roadmap/` — gate-by-gate route to playable gameplay +- `/infamous/data/status.json` — the same small dated snapshot in machine-readable form + +The JSON file is static and reviewed. It is not a runtime payload. + +## Hosting + +This repository is a GitHub Pages **project site**, so its default URL is +`https://bucketcomps.github.io/infamous/`. GitHub documents that a custom domain +assigned to the organization site is inherited by public project sites by +default; after a separately approved cutover, the intended URL is +`https://decomp.deucebucket.com/infamous/`. + +The dynamic stream/dashboard remains a separate locally hosted system. This +site only contains a clearly held link to the proposed +`https://live.decomp.deucebucket.com/infamous` endpoint. + +Official reference: +[About custom domains and GitHub Pages](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/about-custom-domains-and-github-pages). diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..ba1b8a9 --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,17 @@ +# Publishing + +The custom GitHub Pages workflow: + +1. validates every relevant pull request and push; +2. uploads only `site/`; +3. grants deployment permissions only to the deploy job; +4. skips deployment on pull requests; and +5. pins every action to a full commit SHA. + +There is intentionally no `CNAME` file. Custom-domain work belongs to the +organization Pages cutover and is not part of this dossier PR. + +Official references: + +- [Configuring a Pages publishing source](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site) +- [Using custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) diff --git a/docs/SANITIZED-SNAPSHOT.md b/docs/SANITIZED-SNAPSHOT.md new file mode 100644 index 0000000..d427679 --- /dev/null +++ b/docs/SANITIZED-SNAPSHOT.md @@ -0,0 +1,22 @@ +# Sanitized source snapshot + +The initial dossier copy was written from the already published, fail-closed +public release—not from private project documentation. + +Audit record: + +- Public release: `20260723-ee63eaa984f6aee96265` +- Public manifest SHA-256: + `ccbbdacae803bea9be244d096994602078d62c7a78040d2ad2977c6e7629f22f` +- Manifest-enumerated payloads: 55 +- Complete release tree: 56 files, including the release manifest +- Snapshot date: 2026-07-23 + +Only small text facts needed by the static dossier were selected. No image, +video, audio, manifest media entry, live support payload, or raw telemetry file +was copied into this repository. + +The dossier intentionally retains honest zeros: 4 of 13 renderer checklist +steps, 0 draws, 0 flips, 0 captures, and 0 verified frames. Save/load is shown +as 4 of 34 subsystem checks represented; it is not presented as an end-to-end +gameplay save/load proof. diff --git a/docs/SECURITY-BOUNDARY.md b/docs/SECURITY-BOUNDARY.md new file mode 100644 index 0000000..297d902 --- /dev/null +++ b/docs/SECURITY-BOUNDARY.md @@ -0,0 +1,21 @@ +# Public dossier boundary 🔒 + +## Allowed + +- Public-safe prose derived from an already sanitized release +- Dated headline numbers with explicit denominators and proof labels +- Hand-authored HTML and CSS +- Public repository and documentation links +- Generic interface marks made from text and CSS + +## Forbidden + +- Game-derived screenshots, models, textures, video, audio, executables, or data +- ROMs, keys, firmware, SDK material, or decrypted content +- Private wiki/source copies, raw notes, evidence packets, or internal paths +- Credentials, infrastructure coordinates, process metadata, or personal data +- Live telemetry, runtime payloads, automatically copied dashboards, or symlinks + +The workflow uploads only `site/`. A negative-exposure test rejects media-like +files, secret-like strings, internal markers, unpinned actions, broken links, +symlinks, and accidental custom-domain files. diff --git a/scripts/check_site.py b/scripts/check_site.py new file mode 100644 index 0000000..24fbf5e --- /dev/null +++ b/scripts/check_site.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Fail closed unless the complete public repository matches its reviewed projection.""" + +from __future__ import annotations + +import argparse +from html.parser import HTMLParser +import json +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import urlsplit + + +DEFAULT_ROOT = Path(__file__).resolve().parent.parent +BASE_PATH = "/infamous/" +AUTOMATION_NAME = "BucketComps Automation" +AUTOMATION_EMAIL = "bucketcomps-automation@users.noreply.github.com" + +EXPECTED_REPOSITORY_FILES = { + ".editorconfig", + ".gitattributes", + ".github/CODEOWNERS", + ".github/dependabot.yml", + ".github/workflows/pages.yml", + ".gitignore", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "SECURITY.md", + "docs/ARCHITECTURE.md", + "docs/PUBLISHING.md", + "docs/SANITIZED-SNAPSHOT.md", + "docs/SECURITY-BOUNDARY.md", + "scripts/check_site.py", + "site/404.html", + "site/assets/site.css", + "site/data/status.json", + "site/devlog/index.html", + "site/index.html", + "site/method/index.html", + "site/roadmap/index.html", + "tests/test_check_site.py", +} +EXPECTED_SITE_FILES = { + "404.html", + "assets/site.css", + "data/status.json", + "devlog/index.html", + "index.html", + "method/index.html", + "roadmap/index.html", +} +EXPECTED_ACTIONS = ( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d", + "actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9", + "actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128", +) +EXPECTED_STATUS = { + "schema_version": 1, + "as_of": "2026-07-23", + "renderer": { + "steps_done": 4, + "steps_total": 13, + "draws": 0, + "flips": 0, + "captures": 0, + "verified_frames": 0, + }, + "lifted_fallback": { + "functions": 20298, + "inventory_total": 20298, + "clean_source_proof": False, + }, + "save_system": { + "checks_represented": 4, + "checks_total": 34, + "end_to_end_gameplay_cycle": False, + }, + "texture_decode": { + "percent": 98.1, + "records": 15437, + }, + "model": { + "name": "ArcEndian Fast", + "verified_pass": 18, + "verified_total": 75, + "accuracy_percent": 24.0, + "accepted_into_source": 0, + }, + "finish_line": "verified playable gameplay with working save and load", +} +FORBIDDEN_SUFFIXES = { + ".3ds", + ".avi", + ".bin", + ".dds", + ".elf", + ".iso", + ".mkv", + ".mov", + ".mp3", + ".mp4", + ".ogg", + ".png", + ".psarc", + ".self", + ".sprx", + ".wav", +} +FORBIDDEN_FRAGMENTS = ( + "/" + "var" + "/" + "home" + "/", + "/" + "home" + "/", + "infamous" + "-decomp", + "proof" + "_scope", + "BEGIN" + " PRIVATE KEY", + "CF_" + "API_TOKEN", + "GITHUB_" + "TOKEN", + "gmail" + ".com", + "jerry" + "mares", + "Jerry" + " Mares", +) +SECRET_PATTERNS = ( + re.compile(r"gh[opsu]_[A-Za-z0-9]{20,}"), + re.compile(r"sk-[A-Za-z0-9_-]{20,}"), +) +EMAIL_PATTERN = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b") + + +class Document(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[tuple[str, str]] = [] + self.ids: set[str] = set() + self.h1 = 0 + self.lang = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if tag == "html" and values.get("lang"): + self.lang = True + if values.get("id"): + self.ids.add(values["id"] or "") + if tag == "h1": + self.h1 += 1 + if tag in {"a", "link", "script"}: + attr = "href" if tag in {"a", "link"} else "src" + if values.get(attr): + self.links.append((tag, values[attr] or "")) + + +def fail(message: str) -> None: + raise AssertionError(message) + + +def public_files(root: Path) -> list[Path]: + result: list[Path] = [] + for path in root.rglob("*"): + relative = path.relative_to(root) + if ".git" in relative.parts: + continue + if path.is_symlink(): + fail(f"symlink forbidden in public repository: {relative.as_posix()}") + if path.is_file(): + result.append(path) + return sorted(result) + + +def check_inventory(root: Path, files: list[Path]) -> None: + actual = {path.relative_to(root).as_posix() for path in files} + missing = sorted(EXPECTED_REPOSITORY_FILES - actual) + extra = sorted(actual - EXPECTED_REPOSITORY_FILES) + if missing or extra: + fail(f"repository inventory mismatch; missing={missing}, extra={extra}") + + site = root / "site" + actual_site = { + path.relative_to(site).as_posix() + for path in files + if path.is_relative_to(site) + } + missing_site = sorted(EXPECTED_SITE_FILES - actual_site) + extra_site = sorted(actual_site - EXPECTED_SITE_FILES) + if missing_site or extra_site: + fail(f"published inventory mismatch; missing={missing_site}, extra={extra_site}") + + if any(path.suffix.lower() in FORBIDDEN_SUFFIXES for path in files): + fail("game/media-like file forbidden in public repository") + + +def check_disclosures(root: Path, files: list[Path]) -> None: + for path in files: + relative = path.relative_to(root).as_posix() + try: + text = path.read_text(encoding="utf-8") + except UnicodeError as error: + raise AssertionError( + f"non-UTF-8 file forbidden in public repository: {relative}" + ) from error + for fragment in FORBIDDEN_FRAGMENTS: + if fragment.lower() in text.lower(): + fail(f"forbidden disclosure marker in {relative}") + for pattern in SECRET_PATTERNS: + if pattern.search(text): + fail(f"secret-like token in {relative}") + for email in EMAIL_PATTERN.findall(text): + if email.lower() != AUTOMATION_EMAIL: + fail(f"non-automation email address in {relative}") + + +def local_target(site: Path, source: Path, link: str) -> Path: + parsed = urlsplit(link) + path = parsed.path + if path.startswith(BASE_PATH): + return site / path.removeprefix(BASE_PATH) + if path.startswith("/"): + fail(f"absolute local link escapes {BASE_PATH}: {link}") + return source if not path else source.parent / path + + +def check_documents(root: Path) -> dict[Path, Document]: + site = root / "site" + documents: dict[Path, Document] = {} + for path in sorted(site.rglob("*.html")): + document = Document() + document.feed(path.read_text(encoding="utf-8")) + documents[path.resolve()] = document + if document.h1 != 1 or not document.lang: + fail(f"{path.relative_to(site)} needs one h1 and an html language") + if "#main" not in [value for tag, value in document.links if tag == "a"]: + fail(f"{path.relative_to(site)} is missing a skip link") + + for source, document in documents.items(): + for _tag, link in document.links: + parsed = urlsplit(link) + if parsed.scheme: + if parsed.scheme != "https": + fail(f"non-HTTPS link in {source.relative_to(site)}: {link}") + continue + if link.startswith("//"): + fail(f"scheme-relative link forbidden: {link}") + target = local_target(site, source, link) + if target.is_dir(): + target /= "index.html" + if not target.exists(): + fail(f"broken local link in {source.relative_to(site)}: {link}") + if parsed.fragment and target.suffix == ".html": + target_document = documents.get(target.resolve()) + if target_document is None: + target_document = Document() + target_document.feed(target.read_text(encoding="utf-8")) + if parsed.fragment not in target_document.ids: + fail(f"missing fragment in {source.relative_to(site)}: {link}") + + not_found = (site / "404.html").read_text(encoding="utf-8") + required_404_links = ( + 'href="/infamous/assets/site.css"', + 'href="/infamous/"', + ) + if any(marker not in not_found for marker in required_404_links): + fail(f"404 document must preserve the {BASE_PATH} project base") + + architecture = (root / "docs/ARCHITECTURE.md").read_text(encoding="utf-8") + architecture_paths = ( + "`/infamous/`", + "`/infamous/devlog/`", + "`/infamous/method/`", + "`/infamous/roadmap/`", + "`/infamous/data/status.json`", + ) + if any(path not in architecture for path in architecture_paths): + fail(f"architecture content map must use the {BASE_PATH} project base") + return documents + + +def channel(value: int) -> float: + component = value / 255 + return component / 12.92 if component <= 0.04045 else ((component + 0.055) / 1.055) ** 2.4 + + +def contrast_ratio(foreground: str, background: str) -> float: + colors = [] + for value in (foreground, background): + red, green, blue = (int(value[index:index + 2], 16) for index in (1, 3, 5)) + colors.append(0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue)) + lighter, darker = sorted(colors, reverse=True) + return (lighter + 0.05) / (darker + 0.05) + + +def check_accessibility_contract(root: Path) -> None: + css = (root / "site/assets/site.css").read_text(encoding="utf-8") + required_css = ".callout > .kicker {\n color: #284b50;\n}" + if required_css not in css: + fail("callout kicker color must remain explicitly scoped to the paper surface") + ratio = contrast_ratio("#284b50", "#ece5d2") + if ratio < 4.5: + fail(f"callout kicker contrast is below WCAG AA: {ratio:.2f}:1") + + index = (root / "site/index.html").read_text(encoding="utf-8") + if '