From 4cbf6c1a3ff2f7d3e7748e923ee456a53d53d0de Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 2 Aug 2026 16:49:44 +0200 Subject: [PATCH] feat: scaffold Rumble bot catalog --- .github/ISSUE_TEMPLATE/bug-report.yml | 18 ++ .github/ISSUE_TEMPLATE/moderation.yml | 12 + .../PULL_REQUEST_TEMPLATE/bot-submission.md | 7 + .github/workflows/publish-catalog.yml | 27 +++ .github/workflows/validate.yml | 34 +++ .gitignore | 4 + CODEOWNERS | 1 + CONTRIBUTING.md | 11 + GOVERNANCE.md | 9 + README.md | 19 +- bots/banned.json | 5 + bots/index.json | 20 ++ bots/owners.json | 15 ++ bots/python/Orbit/Orbit.cmd | 2 + bots/python/Orbit/Orbit.json | 10 + bots/python/Orbit/Orbit.sh | 10 + bots/python/Orbit/src/Orbit.py | 25 ++ scripts/validate_bot.py | 219 ++++++++++++++++++ tests/test_validate_bot.py | 63 +++++ 19 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/moderation.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE/bot-submission.md create mode 100644 .github/workflows/publish-catalog.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md create mode 100644 GOVERNANCE.md create mode 100644 bots/banned.json create mode 100644 bots/index.json create mode 100644 bots/owners.json create mode 100644 bots/python/Orbit/Orbit.cmd create mode 100644 bots/python/Orbit/Orbit.json create mode 100644 bots/python/Orbit/Orbit.sh create mode 100644 bots/python/Orbit/src/Orbit.py create mode 100644 scripts/validate_bot.py create mode 100644 tests/test_validate_bot.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..3e7c405 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,18 @@ +name: Bug report +description: Report a validator, catalog, or governance problem. +title: "bug: " +labels: [bug] +body: + - type: textarea + id: observed + attributes: + label: What happened? + description: Include the validator command and its complete diagnostic. Do not include secrets. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/moderation.yml b/.github/ISSUE_TEMPLATE/moderation.yml new file mode 100644 index 0000000..8a435cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/moderation.yml @@ -0,0 +1,12 @@ +name: Moderation request +description: Report an ownership, licensing, safety, or conduct concern privately where possible. +title: "moderation: " +labels: [moderation] +body: + - type: textarea + id: concern + attributes: + label: Concern + description: Describe the bot or account involved and the relevant evidence. Do not disclose personal data unnecessarily. + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE/bot-submission.md b/.github/PULL_REQUEST_TEMPLATE/bot-submission.md new file mode 100644 index 0000000..e8b6ba7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bot-submission.md @@ -0,0 +1,7 @@ +## Bot submission + +- [ ] I added source only under `bots///`. +- [ ] I included `.json`, `.sh`, and `.cmd`. +- [ ] The config declares an allowed SPDX `license` and the source uses an official Tank Royale Bot API. +- [ ] I ran `python scripts/validate_bot.py --root . --owner --smoke` successfully. +- [ ] I certify I can publish this source under the declared license. diff --git a/.github/workflows/publish-catalog.yml b/.github/workflows/publish-catalog.yml new file mode 100644 index 0000000..994b803 --- /dev/null +++ b/.github/workflows/publish-catalog.yml @@ -0,0 +1,27 @@ +name: Publish bot catalog + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + publish: + if: github.event.pull_request.merged && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: main + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python scripts/validate_bot.py --root . --owner '${{ github.event.pull_request.user.login }}' --smoke --generate + - run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add bots/index.json bots/owners.json + git diff --cached --quiet || git commit -m 'chore: regenerate bot catalog' + git push diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..6cb63c3 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,34 @@ +name: Validate bot submission + +on: + pull_request: + paths: + - bots/** + - scripts/** + - .github/workflows/validate.yml + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tests -v + - name: Reject hand-edited generated catalog files + run: | + if git cat-file -e origin/${{ github.base_ref }}:bots/index.json 2>/dev/null && git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -qx 'bots/index.json'; then + echo 'bots/index.json is generated by CI and cannot be edited in a pull request.' + exit 1 + fi + if git cat-file -e origin/${{ github.base_ref }}:bots/owners.json 2>/dev/null && git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -qx 'bots/owners.json'; then + echo 'bots/owners.json is generated by CI and cannot be edited in a pull request.' + exit 1 + fi + - run: python scripts/validate_bot.py --root . --owner '${{ github.event.pull_request.user.login }}' --smoke diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93c42f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..7919e7b --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @flemming-n-larsen diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e4aea1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing a ranked bot + +Submit source code only. Bots must use an official Tank Royale Bot API for Java, C#, Python, or TypeScript and run through the checked-in booter-convention scripts. Do not submit binary artifacts, generated dependency folders, custom protocol clients, process launchers, raw sockets, or code that writes outside its temporary directory. + +Each bot directory's `license` field is its license grant for the complete directory. By opening a pull request, you certify that you have the right to publish the submitted code under that SPDX license, in the spirit of the Developer Certificate of Origin. Include a full license file if it helps users, but it is optional. + +The first merged pull request for a bot name reserves that name for its submitting forge account. Only that account, or an account later registered in `bots/owners.json` by an already registered account, may submit later versions. A source change requires a version bump; only the latest version remains active. Each owner has five active entries by default, including a TwinDuel team. + +Use `python scripts/validate_bot.py --root . --owner --smoke` before opening a pull request. CI is authoritative. A green check does not replace moderator review, especially for a first submission. + +Copyright complaints, unsafe code, impersonation, and disputes are handled under [GOVERNANCE.md](GOVERNANCE.md). Do not include secrets in source, bot configuration, issues, or pull-request text. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..9e12504 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,9 @@ +# Governance + +The `robocode-dev` organization owns this repository. At least three moderators should retain ownership and CODEOWNERS review rights before ranked submissions open. One moderator approval and successful validation are required for a new bot; trusted owners may receive auto-merge eligibility for version bumps after repeated clean submissions. + +Moderators maintain `bots/banned.json` through reviewed pull requests. A ban can cover an account or a bot and can be temporary. CI prevents a banned account from submitting and excludes disqualified bots from the generated catalog. Facts in the future `rumble-data` repository are never deleted merely because an entry is disqualified. + +Moderators resolve name-squatting, lost-account recovery, license complaints, and appeal requests case by case. A credible copyright complaint removes the bot from the working tree and disqualifies it pending resolution. + +Once each quarter, a moderator performs a fork drill: fork the repository, enable Actions, run validation, and regenerate the catalog without credentials other than the forge-provided token. Record the outcome in a GitHub issue. diff --git a/README.md b/README.md index c55c96f..c5a0f1f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ -# rumble-bots +# Tank Royale Rumble bots + +`rumble-bots` is the reviewable, source-only catalog of bots eligible for ranked Tank Royale Rumble battles. Authors submit bots through pull requests; clients consume the generated `bots/index.json` catalog and run exactly the source tree named by its SHA-256 hash. + +## Submit a bot + +1. Copy [the bot template](.github/PULL_REQUEST_TEMPLATE/bot-submission.md) into `bots///`. +2. Include `.json`, `.sh`, `.cmd`, and source code that uses an official Tank Royale Bot API. +3. Declare one permitted SPDX license: `MIT`, `Apache-2.0`, `BSD-3-Clause`, or `GPL-3.0-or-later`. +4. Run `python scripts/validate_bot.py --root . --owner --smoke` before opening a pull request. + +Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, licensing, and moderation rules. The current validator proves the source-run entry point starts; connecting the smoke check to a temporary Tank Royale server is the next hardening increment before public ranked submissions open. + +## Catalog + +`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, and active lifecycle status. Only `active` entries are eligible for matchmaking. + +This repository is designed to be forkable: its validator is standard-library Python and GitHub Actions only invokes that script. The only forge seam is the workflow that supplies the pull-request author to the validator and publishes generated files after merge. Source-only catalog for ranked Tank Royale Rumble bots diff --git a/bots/banned.json b/bots/banned.json new file mode 100644 index 0000000..5311905 --- /dev/null +++ b/bots/banned.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "bannedOwners": [], + "disqualifiedBots": [] +} diff --git a/bots/index.json b/bots/index.json new file mode 100644 index 0000000..38e7026 --- /dev/null +++ b/bots/index.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-08-02T14:52:08Z", + "commit": "local", + "bots": [ + { + "name": "Orbit", + "version": "1.0.2", + "platform": "Python", + "path": "bots/python/Orbit", + "sourceHash": "sha256:86b63e60f231c8f736ec7dc7867c93966a59a63bb3409326516879236555cf36", + "owner": "flemming-n-larsen", + "authors": [ + "Tank Royale Rumble maintainers" + ], + "addedAt": "2026-08-02", + "status": "active" + } + ] +} diff --git a/bots/owners.json b/bots/owners.json new file mode 100644 index 0000000..96c3567 --- /dev/null +++ b/bots/owners.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "owners": [ + { + "ownerId": "flemming-n-larsen", + "accounts": [ + "flemming-n-larsen" + ], + "bots": [ + "Orbit" + ], + "activeSlots": 1 + } + ] +} diff --git a/bots/python/Orbit/Orbit.cmd b/bots/python/Orbit/Orbit.cmd new file mode 100644 index 0000000..97dd726 --- /dev/null +++ b/bots/python/Orbit/Orbit.cmd @@ -0,0 +1,2 @@ +@echo off +python "%~dp0src\Orbit.py" %* diff --git a/bots/python/Orbit/Orbit.json b/bots/python/Orbit/Orbit.json new file mode 100644 index 0000000..ca07a9c --- /dev/null +++ b/bots/python/Orbit/Orbit.json @@ -0,0 +1,10 @@ +{ + "name": "Orbit", + "version": "1.0.2", + "authors": ["Tank Royale Rumble maintainers"], + "description": "Minimal source-run validation bot.", + "platform": "Python", + "programmingLang": "Python 3", + "gameTypes": ["1v1", "melee", "twinduel"], + "license": "Apache-2.0" +} diff --git a/bots/python/Orbit/Orbit.sh b/bots/python/Orbit/Orbit.sh new file mode 100644 index 0000000..5594640 --- /dev/null +++ b/bots/python/Orbit/Orbit.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ -n "${RUMBLE_PYTHON:-}" ]; then + exec "$RUMBLE_PYTHON" "$SCRIPT_DIR/src/Orbit.py" "$@" +fi +if command -v python3 >/dev/null 2>&1; then + exec python3 "$SCRIPT_DIR/src/Orbit.py" "$@" +fi +exec python "$SCRIPT_DIR/src/Orbit.py" "$@" diff --git a/bots/python/Orbit/src/Orbit.py b/bots/python/Orbit/src/Orbit.py new file mode 100644 index 0000000..b42595a --- /dev/null +++ b/bots/python/Orbit/src/Orbit.py @@ -0,0 +1,25 @@ +"""Minimal smokeable Rumble bot entry point.""" + +import os + + +if os.environ.get("RUMBLE_SMOKE") == "1": + print("RUMBLE_SMOKE_READY Orbit") + raise SystemExit(0) + +from robocode_tank_royale.bot_api.bot import Bot + + +class Orbit(Bot): + """A deliberately small official-API bot used to exercise the submission pipeline.""" + + def run(self) -> None: + self.turn_radar_left(360) + + +def main() -> None: + Orbit().start() + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_bot.py b/scripts/validate_bot.py new file mode 100644 index 0000000..287e323 --- /dev/null +++ b/scripts/validate_bot.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Validate Rumble bot submissions and generate their catalog using only the Python standard library.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import unicodedata +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +ALLOWED_LICENSES = {"MIT", "Apache-2.0", "BSD-3-Clause", "GPL-3.0-or-later"} +PLATFORMS = { + "csharp": (".cs", "C#", "Robocode.TankRoyale.BotApi"), + "java": (".java", "JVM", "dev.robocode.tankroyale.botapi"), + "python": (".py", "Python", "robocode_tank_royale"), + "typescript": (".ts", "TypeScript", "@robocode/tank-royale"), +} +FORBIDDEN_SUFFIXES = {".dll", ".exe", ".jar", ".so", ".dylib", ".pyc", ".class", ".zip", ".tar", ".gz"} +FORBIDDEN_TOKENS = ("processbuilder", "runtime.exec", "subprocess", "os.system", "child_process", "system.diagnostics.process", "socket", "ctypes", "dllimport", "eval(") +LEET = str.maketrans({"0": "o", "1": "l", "3": "e", "4": "a", "5": "s", "7": "t", "8": "b", "9": "g", "@": "a", "$": "s", "!": "i", "|": "l"}) + + +class ValidationError(Exception): + """An actionable submission validation failure.""" + + +@dataclass(frozen=True) +class Bot: + directory: Path + platform_key: str + config: dict[str, Any] + source_hash: str + + @property + def name(self) -> str: + return str(self.config["name"]) + + +def read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValidationError(f"{path}: invalid JSON: {error}") from error + if not isinstance(value, dict): + raise ValidationError(f"{path}: expected a JSON object") + return value + + +def skeleton(name: str) -> str: + normalized = unicodedata.normalize("NFKD", name).casefold().translate(LEET) + return "".join(character for character in normalized if character.isalnum()) + + +def tree_hash(directory: Path) -> str: + digest = hashlib.sha256() + for path in sorted(candidate for candidate in directory.rglob("*") if candidate.is_file() and "__pycache__" not in candidate.parts): + digest.update(path.relative_to(directory).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return f"sha256:{digest.hexdigest()}" + + +def bot_directories(root: Path) -> list[tuple[str, Path]]: + bots_root = root / "bots" + result: list[tuple[str, Path]] = [] + for platform_dir in sorted(path for path in bots_root.iterdir() if path.is_dir()): + if platform_dir.name not in PLATFORMS: + raise ValidationError(f"{platform_dir}: unsupported platform directory") + result.extend((platform_dir.name, path) for path in sorted(platform_dir.iterdir()) if path.is_dir()) + return result + + +def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: + source_extension, expected_platform, api_token = PLATFORMS[platform_key] + config = read_json(directory / f"{directory.name}.json") + for field in ("name", "version", "authors", "platform", "license"): + if not config.get(field): + raise ValidationError(f"{directory}: missing required `{field}` in {directory.name}.json") + if config["name"] != directory.name: + raise ValidationError(f"{directory}: directory name must equal config name `{config['name']}`") + if config["platform"] != expected_platform: + raise ValidationError(f"{directory}: `{platform_key}` entries require platform `{expected_platform}`") + if not isinstance(config["authors"], list) or not all(isinstance(author, str) and author for author in config["authors"]): + raise ValidationError(f"{directory}: `authors` must be a non-empty list of display names") + if config["license"] not in ALLOWED_LICENSES: + raise ValidationError(f"{directory}: `license` must be one of {', '.join(sorted(ALLOWED_LICENSES))}") + for suffix in (".sh", ".cmd"): + if not (directory / f"{directory.name}{suffix}").is_file(): + raise ValidationError(f"{directory}: missing required {directory.name}{suffix} boot script") + source_files = list(directory.rglob(f"*{source_extension}")) + if not source_files: + raise ValidationError(f"{directory}: no {source_extension} source file found") + source_text = "\n".join(path.read_text(encoding="utf-8") for path in source_files).lower() + if api_token.lower() not in source_text: + raise ValidationError(f"{directory}: source must reference the official Tank Royale {expected_platform} Bot API") + for path in directory.rglob("*"): + if not path.is_file(): + continue + if "__pycache__" in path.parts: + continue + if path.suffix.lower() in FORBIDDEN_SUFFIXES: + raise ValidationError(f"{path}: binary and archive artifacts are not allowed") + for token in FORBIDDEN_TOKENS: + if token in source_text: + raise ValidationError(f"{directory}: source contains restricted construct `{token}` for moderator review") + bot = Bot(directory, platform_key, config, tree_hash(directory)) + if smoke: + smoke_bot(bot) + return bot + + +def smoke_bot(bot: Bot) -> None: + script = bot.directory / f"{bot.name}.sh" + python_executable = str(Path(sys.executable)) + if os.name == "nt": + python_executable = "/" + python_executable[0].lower() + python_executable[2:].replace("\\", "/") + environment = os.environ | {"RUMBLE_PYTHON": python_executable, "RUMBLE_SMOKE": "1"} + try: + completed = subprocess.run(["sh", str(script)], cwd=bot.directory, env=environment, text=True, capture_output=True, timeout=20, check=False) + except OSError as error: + raise ValidationError(f"{bot.directory}: cannot start source-run smoke check: {error}") from error + if completed.returncode != 0 or f"RUMBLE_SMOKE_READY {bot.name}" not in completed.stdout: + raise ValidationError(f"{bot.directory}: source-run smoke check failed: {completed.stderr.strip() or completed.stdout.strip()}") + + +def check_governance(bots: list[Bot], root: Path, owner: str) -> None: + owners = read_json(root / "bots" / "owners.json") if (root / "bots" / "owners.json").exists() else {"owners": []} + banned = read_json(root / "bots" / "banned.json") + banned_accounts = {entry["account"] for entry in banned.get("bannedOwners", [])} + disqualified_names = {entry["bot"] for entry in banned.get("disqualifiedBots", [])} + if owner in banned_accounts: + raise ValidationError(f"owner `{owner}` is banned from submissions") + known_names: dict[str, str] = {name: record["ownerId"] for record in owners.get("owners", []) for name in record.get("bots", [])} + catalog_entries = read_json(root / "bots" / "index.json").get("bots", []) if (root / "bots" / "index.json").exists() else [] + catalog_by_name = {entry["name"]: entry for entry in catalog_entries if entry.get("status") == "active"} + seen_skeletons: dict[str, str] = {} + for bot in bots: + if bot.name in disqualified_names: + raise ValidationError(f"bot `{bot.name}` is disqualified") + bot_skeleton = skeleton(bot.name) + previous = seen_skeletons.get(bot_skeleton) + if previous is not None and previous != bot.name: + raise ValidationError(f"bot `{bot.name}` is confusable with `{previous}`") + seen_skeletons[bot_skeleton] = bot.name + existing_owner = known_names.get(bot.name) + if existing_owner is not None and existing_owner != owner: + raise ValidationError(f"bot `{bot.name}` belongs to owner `{existing_owner}`") + previous = catalog_by_name.get(bot.name) + if previous is not None and previous.get("version") == bot.config["version"] and previous.get("sourceHash") != bot.source_hash: + raise ValidationError(f"bot `{bot.name}` changed source without increasing its version") + active_by_owner: dict[str, int] = {} + for bot in bots: + active_by_owner[known_names.get(bot.name, owner)] = active_by_owner.get(known_names.get(bot.name, owner), 0) + 1 + if active_by_owner.get(owner, 0) > 5: + raise ValidationError(f"owner `{owner}` exceeds the five active bot slot limit") + + +def generated_catalog(bots: list[Bot], root: Path, owner: str) -> tuple[dict[str, Any], dict[str, Any]]: + existing_owners = read_json(root / "bots" / "owners.json") if (root / "bots" / "owners.json").exists() else {"schemaVersion": 1, "owners": []} + existing_catalog = read_json(root / "bots" / "index.json") if (root / "bots" / "index.json").exists() else {"bots": []} + owner_by_bot = {bot_name: record["ownerId"] for record in existing_owners.get("owners", []) for bot_name in record.get("bots", [])} + records: dict[str, list[str]] = {} + for bot in bots: + bot_owner = owner_by_bot.get(bot.name, owner) + records.setdefault(bot_owner, []).append(bot.name) + owners = {record["ownerId"]: set(record.get("bots", [])) for record in existing_owners.get("owners", [])} + for owner_id, names in records.items(): + owners.setdefault(owner_id, set()).update(names) + owner_data = {"schemaVersion": 1, "owners": [{"ownerId": owner_id, "accounts": [owner_id], "bots": sorted(names), "activeSlots": len(names)} for owner_id, names in sorted(owners.items())]} + today = datetime.now(UTC).date().isoformat() + current_by_name = {bot.name: bot for bot in bots} + history = [] + for entry in existing_catalog.get("bots", []): + current = current_by_name.get(entry.get("name")) + if current is not None and entry.get("version") != current.config["version"] and entry.get("status") == "active": + history.append(entry | {"status": "superseded"}) + elif current is None: + history.append(entry) + active = [] + for bot in sorted(bots, key=lambda item: item.name.casefold()): + previous = next((entry for entry in existing_catalog.get("bots", []) if entry.get("name") == bot.name and entry.get("version") == bot.config["version"]), None) + active.append({"name": bot.name, "version": bot.config["version"], "platform": bot.config["platform"], "path": bot.directory.relative_to(root).as_posix(), "sourceHash": bot.source_hash, "owner": owner_by_bot.get(bot.name, owner), "authors": bot.config["authors"], "addedAt": previous.get("addedAt", today) if previous else today, "status": "active"}) + catalog = {"schemaVersion": 1, "generatedAt": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "commit": os.environ.get("GITHUB_SHA", "local"), "bots": history + active} + return catalog, owner_data + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--owner", required=True) + parser.add_argument("--smoke", action="store_true") + parser.add_argument("--generate", action="store_true") + arguments = parser.parse_args() + root = arguments.root.resolve() + try: + bots = [validate_bot(platform, directory, smoke=arguments.smoke) for platform, directory in bot_directories(root)] + check_governance(bots, root, arguments.owner) + if arguments.generate: + catalog, owners = generated_catalog(bots, root, arguments.owner) + (root / "bots" / "index.json").write_text(json.dumps(catalog, indent=2) + "\n", encoding="utf-8") + (root / "bots" / "owners.json").write_text(json.dumps(owners, indent=2) + "\n", encoding="utf-8") + except ValidationError as error: + print(f"validation failed: {error}", file=sys.stderr) + return 1 + print(f"validated {len(bots)} bot(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_bot.py b/tests/test_validate_bot.py new file mode 100644 index 0000000..c42d349 --- /dev/null +++ b/tests/test_validate_bot.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPOSITORY = Path(__file__).parents[1] +VALIDATOR = REPOSITORY / "scripts" / "validate_bot.py" + + +class ValidatorIntegrationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + shutil.copytree(REPOSITORY / "bots", self.root / "bots") + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def run_validator(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(VALIDATOR), "--root", str(self.root), "--owner", "flemming-n-larsen", *arguments], text=True, capture_output=True, check=False) + + def test_valid_submission_generates_an_active_catalog_entry(self) -> None: + result = self.run_validator("--smoke", "--generate") + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8")) + active_entry = next(entry for entry in catalog["bots"] if entry["status"] == "active") + self.assertEqual("Orbit", active_entry["name"]) + + def test_invalid_license_is_rejected(self) -> None: + config_path = self.root / "bots" / "python" / "Orbit" / "Orbit.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["license"] = "Proprietary" + config_path.write_text(json.dumps(config), encoding="utf-8") + result = self.run_validator() + self.assertNotEqual(0, result.returncode) + self.assertIn("license", result.stderr) + + def test_source_change_without_version_increase_is_rejected(self) -> None: + self.assertEqual(0, self.run_validator("--generate").returncode) + source_path = self.root / "bots" / "python" / "Orbit" / "src" / "Orbit.py" + source_path.write_text(source_path.read_text(encoding="utf-8") + "\n# changed\n", encoding="utf-8") + result = self.run_validator() + self.assertNotEqual(0, result.returncode) + self.assertIn("without increasing its version", result.stderr) + + def test_version_increase_supersedes_the_previous_catalog_entry(self) -> None: + self.assertEqual(0, self.run_validator("--generate").returncode) + config_path = self.root / "bots" / "python" / "Orbit" / "Orbit.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["version"] = "1.0.3" + config_path.write_text(json.dumps(config), encoding="utf-8") + self.assertEqual(0, self.run_validator("--generate").returncode) + catalog = json.loads((self.root / "bots" / "index.json").read_text(encoding="utf-8")) + self.assertEqual(["superseded", "active"], [entry["status"] for entry in catalog["bots"]]) + + +if __name__ == "__main__": + unittest.main()