Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/scripts/test_validate_game_dir.py
Original file line number Diff line number Diff line change
@@ -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()
101 changes: 88 additions & 13 deletions .github/scripts/validate_game_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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/<username>/<name>")
d = sys.argv[1].rstrip("/")
d = Path(sys.argv[1].rstrip("/"))

# The whole path shape is the contract: games/<username>/<name> 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/<username>/<name>")
owner, name = parts[-2], parts[-1]
Expand All @@ -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:
Expand All @@ -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)")

Expand Down
24 changes: 10 additions & 14 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<shellcade-username>/...` 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.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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).
6 changes: 3 additions & 3 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<game>` 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) |
Expand All @@ -21,7 +21,7 @@ Your game's platform identity is the path: `<shellcade-username>/<game-name>`.
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

Expand All @@ -37,7 +37,7 @@ Go guest:
```sh
cd games/<you>/<game>
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
Expand Down
2 changes: 2 additions & 0 deletions games/alan/chess/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/chess
2 changes: 2 additions & 0 deletions games/bcook/blackjack/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/blackjack
2 changes: 2 additions & 0 deletions games/bcook/scratchies/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/scratchies
3 changes: 3 additions & 0 deletions games/bcook/shellracer/.gitignore
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions games/laco/paperdrift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/paperdrift
Binary file removed games/laco/paperdrift/paperdrift
Binary file not shown.
2 changes: 2 additions & 0 deletions games/luke/neon-snake/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# build outputs (CI builds what ships; never commit binaries)
/neon-snake
Loading