diff --git a/.github/scripts/test_validate_game_dir.py b/.github/scripts/test_validate_game_dir.py new file mode 100644 index 0000000..c9ed931 --- /dev/null +++ b/.github/scripts/test_validate_game_dir.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("validate_game_dir.py") + + +class ValidateGameDirTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + subprocess.run(["git", "init"], cwd=self.root, check=True, stdout=subprocess.DEVNULL) + self.game = self.root / "games" / "alice" / "breakout" + self.game.mkdir(parents=True) + (self.game / "go.mod").write_text("module shellcade.games/alice/breakout\n", encoding="utf-8") + (self.game / "LICENSE").write_text("MIT License\n", encoding="utf-8") + (self.game / "smoke.yaml").write_text("seed: 1\nseats: 1\nsteps: []\n", encoding="utf-8") + (self.game / "main.go").write_text("package main\n", encoding="utf-8") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def git_add(self) -> None: + subprocess.run(["git", "add", "."], cwd=self.root, check=True, stdout=subprocess.DEVNULL) + + def validate(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), str(self.game)], + cwd=self.root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + def assert_rejected(self, filename: str, content: bytes, expected: str, executable: bool = False) -> None: + path = self.game / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + if executable: + path.chmod(path.stat().st_mode | stat.S_IXUSR) + self.git_add() + result = self.validate() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn(expected, result.stdout) + + def test_valid_game_passes(self) -> None: + self.git_add() + result = self.validate() + self.assertEqual(result.returncode, 0, result.stdout) + + def test_wasm_artifact_is_rejected(self) -> None: + self.assert_rejected("game.wasm", b"\x00asm", "wasm build artifact") + + def test_elf_artifact_is_rejected(self) -> None: + self.assert_rejected("breakout", b"\x7fELFpayload", "ELF executable") + + def test_mach_o_artifact_is_rejected(self) -> None: + self.assert_rejected("breakout", b"\xcf\xfa\xed\xfepayload", "Mach-O executable") + + def test_pe_artifact_is_rejected(self) -> None: + self.assert_rejected("breakout.exe", b"MZpayload", "native build artifact") + + def test_executable_bit_artifact_is_rejected(self) -> None: + self.assert_rejected("breakout", b"native payload", "executable-bit build artifact", executable=True) + + def test_build_output_directory_is_rejected(self) -> None: + self.assert_rejected("target/wasm32-wasip1/release/game.wasm", b"\x00asm", "build-output directory") + + def test_ignored_untracked_local_outputs_are_ignored(self) -> None: + (self.root / ".gitignore").write_text("*.wasm\ntarget/\n", encoding="utf-8") + self.git_add() + (self.game / "game.wasm").write_bytes(b"\x00asm") + target = self.game / "target" / "wasm32-wasip1" / "release" + target.mkdir(parents=True) + (target / "game.wasm").write_bytes(b"\x00asm") + result = self.validate() + self.assertEqual(result.returncode, 0, result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_game_dir.py b/.github/scripts/validate_game_dir.py index 5bb30c6..b317df6 100644 --- a/.github/scripts/validate_game_dir.py +++ b/.github/scripts/validate_game_dir.py @@ -15,11 +15,14 @@ MIT, Apache-2.0, BSD-3-Clause, MPL-2.0, Unlicense - smoke.yaml present (the scripted-screens contract; `shellcade-kit smoke` validates the schema and runs it — this just asserts the file exists) - - no committed build artifacts (*.wasm) + - no committed build artifacts of any kind """ -import os import re +import stat +import subprocess import sys +from pathlib import Path +from typing import Optional ALLOWLIST = [ (re.compile(r"MIT License", re.I), "MIT"), @@ -29,22 +32,94 @@ (re.compile(r"free and unencumbered software", re.I), "Unlicense"), ] +BUILD_DIRS = {"target", "smoke-out", "shots", "dist", "build"} +EXECUTABLE_EXTS = {".exe", ".dll", ".dylib", ".so"} +NATIVE_MAGIC = { + b"\x7fELF": "ELF executable", + b"MZ": "PE executable", + b"\xfe\xed\xfa\xce": "Mach-O executable", + b"\xfe\xed\xfa\xcf": "Mach-O executable", + b"\xce\xfa\xed\xfe": "Mach-O executable", + b"\xcf\xfa\xed\xfe": "Mach-O executable", + b"\xca\xfe\xba\xbe": "Mach-O universal binary", + b"\xbe\xba\xfe\xca": "Mach-O universal binary", +} + def err(msg: str) -> None: print(f"::error::{msg}") sys.exit(1) +def git_root(path: Path) -> Optional[Path]: + try: + out = subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return None + return Path(out) if out else None + + +def tracked_files(d: Path) -> list[Path]: + root = git_root(d) + if root is None: + return [p for p in d.rglob("*") if p.is_file()] + rel = d.resolve().relative_to(root.resolve()).as_posix() + try: + out = subprocess.check_output( + ["git", "-C", str(root), "ls-files", "--", rel], + stderr=subprocess.DEVNULL, + text=True, + ) + except subprocess.CalledProcessError as exc: + err(f"{d}: could not list tracked files: {exc}") + files = [] + for line in out.splitlines(): + p = root / line + if p.is_file(): + files.append(p) + return files + + +def artifact_reason(path: Path, game_dir: Path) -> Optional[str]: + rel = path.resolve().relative_to(game_dir.resolve()) + parts = rel.parts + if any(part in BUILD_DIRS for part in parts[:-1]): + return "file under build-output directory" + if path.suffix == ".wasm": + return "wasm build artifact" + if path.suffix in EXECUTABLE_EXTS: + return "native build artifact" + try: + head = path.read_bytes()[:4] + except OSError: + head = b"" + for magic, reason in NATIVE_MAGIC.items(): + if head.startswith(magic): + return reason + try: + mode = path.stat().st_mode + except OSError: + return None + if mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH): + if path.name not in {"LICENSE"}: + return "executable-bit build artifact" + return None + + def main() -> None: if len(sys.argv) != 2: err("usage: validate_game_dir.py games//") - d = sys.argv[1].rstrip("/") + d = Path(sys.argv[1].rstrip("/")) # The whole path shape is the contract: games// with both # segments bare names. The owner segment is fork-controlled too (it comes # from PR file paths), so it gets the same charset check as the game name. # Anchor on the trailing segments so authors can pass "$(pwd)" locally. - parts = d.split("/") + parts = d.parts if len(parts) < 3 or parts[-3] != "games": err(f"{d}: game directory must be games//") owner, name = parts[-2], parts[-1] @@ -56,13 +131,13 @@ def main() -> None: # guest, Cargo.toml for a Rust guest. The built artifact (and its meta) is # the real contract; this just asserts the sources stand on their own. module_markers = ("go.mod", "Cargo.toml") - if not any(os.path.isfile(os.path.join(d, m)) for m in module_markers): + if not any((d / m).is_file() for m in module_markers): err(f"{d}: no module marker — need go.mod (Go) or Cargo.toml (Rust)") - lic = os.path.join(d, "LICENSE") - if not os.path.isfile(lic): + lic = d / "LICENSE" + if not lic.is_file(): err(f"{d}/LICENSE missing — allowlist: MIT, Apache-2.0, BSD-3-Clause, MPL-2.0, Unlicense") - with open(lic, encoding="utf-8", errors="replace") as f: + with lic.open(encoding="utf-8", errors="replace") as f: head = "\n".join(f.read().splitlines()[:5]) spdx = next((tag for pat, tag in ALLOWLIST if pat.search(head)), None) if spdx is None: @@ -71,13 +146,13 @@ def main() -> None: # The smoke contract: every game ships a smoke.yaml (scripted screens for # PR previews). Schema + run validation happens in `shellcade-kit smoke`; # presence is the static gate. - if not os.path.isfile(os.path.join(d, "smoke.yaml")): + if not (d / "smoke.yaml").is_file(): err(f"{d}/smoke.yaml missing — every game ships a smoke script (see SCHEMA.md)") - for root, _dirs, files in os.walk(d): - for fn in files: - if fn.endswith(".wasm"): - err(f"{os.path.join(root, fn)}: built artifacts are never committed — CI builds what ships") + for path in tracked_files(d): + reason = artifact_reason(path, d) + if reason is not None: + err(f"{path}: committed build artifact rejected ({reason}) — CI builds what ships") print(f"ok: {d} (license={spdx}, module + smoke.yaml + sources present)") diff --git a/.gitignore b/.gitignore index 81a7ab0..1869f11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,11 @@ -# build outputs — the catalog never commits artifacts +# shared build outputs — the catalog never commits artifacts *.wasm - -# native `go build` binaries take the game directory name; authors: run -# `go build ./...` (no output binary) or clean up before committing. -games/*/*/blackjack -games/*/*/pokies -games/*/*/scratchies -games/*/*/shellracer -# ... but the rule above also matches the shellracer SOURCE directory -# (games/bcook/shellracer/shellracer/); re-include the directory so new -# source files there are not silently ignored. The built binary is a file, -# so it stays ignored. -!games/*/*/shellracer/ -games/*/*/neon-snake +*.exe +*.dll +*.dylib +*.so +target/ +smoke-out/ +shots/ +dist/ +build/ diff --git a/CLAUDE.md b/CLAUDE.md index 340bdf5..d8c3726 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,10 @@ maintainer review is the gate while that endpoint's secret is being rolled out. - **NEVER add shellcade-internal material here** — no OpenSpec artifacts, no internal specs/designs, no references to private packages or infra. Platform design lives in the private repo only. -- Never commit built `.wasm` artifacts; CI builds what gets published. +- Never commit build artifacts; CI builds what gets published and rejects wasm, native executable, Rust `target/`, smoke output, and other build outputs. - A PR may only touch `games//...` whose shellcade account is linked to the PR author's GitHub login (the `authorize.yml` API check enforces this once its secret is set; until then, verify the in-arcade link during review). Admins may publish to any namespace. - Review bar: play it (`shellcade-kit play`), read it (it runs sandboxed but we - host it), and check `game.toml` license + slug uniqueness. + host it), and check `LICENSE`, `smoke.yaml`, metadata/path agreement, artifact cleanliness, and slug uniqueness. diff --git a/README.md b/README.md index 1fa8e9d..48666d9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ a game is a pull request.** ├── go.mod # every game is a standalone module ├── main.go … # your game's source (source is required) ├── LICENSE # MIT, Apache-2.0, BSD-3-Clause, MPL-2.0, or Unlicense + ├── smoke.yaml # deterministic smoke script for CI previews └── CHANGELOG.md # optional: top section becomes the release notes **Your slug is the path.** The catalog identity is @@ -39,9 +40,7 @@ a game is a pull request.** linked yet, the check posts the linking steps on your PR. While the API is being rolled out, the check is skipped and maintainer review is the gate. -4. CI also verifies: the directory meets the [contract](SCHEMA.md), the game - builds (TinyGo), `shellcade-kit check` passes — the same gate the arcade - runs — and the artifact's own metadata agrees with the path. +4. CI also verifies: the directory meets the [contract](SCHEMA.md), contains no committed build artifacts, the game builds (TinyGo or Rust), `shellcade-kit check` passes — the same gate the arcade runs — `shellcade-kit smoke` runs, and the artifact's own metadata agrees with the path. 5. A maintainer reviews and merges. Merge = accepted into the catalog; an arcade operator then flips it live, attributed to your handle. @@ -51,7 +50,7 @@ a game is a pull request.** compatible; a `LICENSE` file from the allowlist). - One directory per game, under your own username. CI rejects PRs that touch other authors' games. -- Keep artifacts out of the repo — CI builds the `.wasm` it publishes. +- Keep all build artifacts out of the repo — CI builds the `.wasm` it publishes and rejects committed wasm, native executable, Rust `target/`, smoke output, and other build outputs. - Hosted content must meet the [content policy](CONTENT_POLICY.md) — it also documents how to report a game and how takedowns work (operators can pull a game offline immediately; the catalog entry follows by PR). diff --git a/SCHEMA.md b/SCHEMA.md index ac37d1d..0c1983d 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -10,7 +10,7 @@ nothing is declared twice and nothing can drift. | File | Rule | |---|---| -| module marker | Every game is a **standalone module** in its source language: `go.mod` for a Go guest (any path; `require github.com/shellcade/kit`), or `Cargo.toml` for a Rust guest (a `cdylib` for `wasm32-wasip1`; implement the ABI from `ABI.md` — see `bcook/tic-tac-toe-rs`). The **built artifact** and its `meta` are the real contract; the language is your choice | +| module marker | Every game is a **standalone module** in its source language: `go.mod` for a Go guest (new games should use `module shellcade.games//` and require `github.com/shellcade/kit`), or `Cargo.toml` for a Rust guest (a `cdylib` for `wasm32-wasip1`; implement the ABI from `ABI.md` — see `bcook/tic-tac-toe-rs`). The **built artifact** and its `meta` are the real contract; the language is your choice | | source | Builds with its pinned toolchain profile (TinyGo dev profile, or `cargo build --release --target wasm32-wasip1`) and passes `shellcade-kit check` (the same harness the arcade runs) | | `LICENSE` | One of: **MIT, Apache-2.0, BSD-3-Clause, MPL-2.0, Unlicense** | | `smoke.yaml` | A deterministic smoke script (seed, seats, steps) that drives your game and names screen dumps — CI runs it on every PR (`shellcade-kit smoke`) and posts the screens as a visual preview comment. Smoke scripts drive at most **8 seats** (the runner clamps `minPlayers` to the seat count, so large-room games still pass smoke; large-room behavior is exercised by `check` and your budget tests). Schema + authoring guidance: [kit GUIDE.md "Smoke scripts"](https://github.com/shellcade/kit/blob/main/GUIDE.md#smoke-scripts-scripted-screens) | @@ -21,7 +21,7 @@ Your game's platform identity is the path: `/`. Player bounds (`minPlayers`/`maxPlayers` in your meta) must sit within the platform's 1..1024. -Built artifacts (`*.wasm`) are **never committed** — CI builds what ships. +Build artifacts are **never committed** — CI builds what ships. This includes `*.wasm`, native executables such as Mach-O/ELF/PE outputs, executable-bit build outputs, Rust `target/`, and generated smoke/shot output. The local validator checks Git-tracked files for this policy, so ignored local build outputs do not fail validation. ## Optional @@ -37,7 +37,7 @@ Go guest: ```sh cd games// go mod tidy -tinygo build -opt=1 -no-debug -gc=leaking -o game.wasm -target wasip1 -buildmode=c-shared . +tinygo build -opt=1 -no-debug -gc=conservative -o game.wasm -target wasip1 -buildmode=c-shared . shellcade-kit check game.wasm # full conformance, the merge gate shellcade-kit meta game.wasm # what the platform will read shellcade-kit smoke . # runs smoke.yaml, writes the shot files CI previews diff --git a/games/alan/chess/.gitignore b/games/alan/chess/.gitignore new file mode 100644 index 0000000..4f55863 --- /dev/null +++ b/games/alan/chess/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/chess diff --git a/games/bcook/blackjack/.gitignore b/games/bcook/blackjack/.gitignore new file mode 100644 index 0000000..23eea54 --- /dev/null +++ b/games/bcook/blackjack/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/blackjack diff --git a/games/bcook/scratchies/.gitignore b/games/bcook/scratchies/.gitignore new file mode 100644 index 0000000..67a55e9 --- /dev/null +++ b/games/bcook/scratchies/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/scratchies diff --git a/games/bcook/shellracer/.gitignore b/games/bcook/shellracer/.gitignore index 3a692e5..843b091 100644 --- a/games/bcook/shellracer/.gitignore +++ b/games/bcook/shellracer/.gitignore @@ -1,6 +1,9 @@ # Built artifacts are never committed; CI builds what gets published. *.wasm +# Native dev binary. The source package lives in /shellracer/, so only ignore the file. +/shellracer + # Local module resolution for the private kit module (machine-local paths). go.work go.work.sum diff --git a/games/laco/paperdrift/.gitignore b/games/laco/paperdrift/.gitignore new file mode 100644 index 0000000..974d9ff --- /dev/null +++ b/games/laco/paperdrift/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/paperdrift diff --git a/games/laco/paperdrift/paperdrift b/games/laco/paperdrift/paperdrift deleted file mode 100755 index ac4efc2..0000000 Binary files a/games/laco/paperdrift/paperdrift and /dev/null differ diff --git a/games/luke/neon-snake/.gitignore b/games/luke/neon-snake/.gitignore new file mode 100644 index 0000000..06a6a70 --- /dev/null +++ b/games/luke/neon-snake/.gitignore @@ -0,0 +1,2 @@ +# build outputs (CI builds what ships; never commit binaries) +/neon-snake