diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3bcfe0a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Bot sources are stored and checked out with Unix line endings on every system, so a bot has the same content hash everywhere. +* text=auto eol=lf +*.sh text eol=lf +*.cmd text eol=lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c98685b..13db186 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Each bot directory's `license` field is its license grant for the complete direc 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 bot-only PR can be merged automatically after source and security checks and a completed one-round battle. Keep launcher scripts faithful to the published starter; modified launchers, team metadata, security uncertainty and unrelated file changes go to the Tank Royale Rumble moderators. If the bot does not run, the PR receives a changes-requested review explaining why; push a fix to rerun moderation. +Use `python scripts/validate_bot.py --root . --owner --smoke` before opening a pull request. CI is authoritative. A bot-only PR can be merged automatically after source and security checks and a completed one-round battle. Files must have Unix line endings (LF). The repository stores them that way, and the validator rejects Windows line endings (CRLF) and names the file. Keep launcher scripts faithful to the published starter; modified launchers, team metadata, security uncertainty and unrelated file changes go to the Tank Royale Rumble moderators. If the bot does not run, the PR receives a changes-requested review explaining why; push a fix to rerun moderation. Start from the [runnable templates and copy-and-rename instructions](templates/README.md). They include pinned dependencies, both launchers and the required `RUMBLE_SMOKE_READY ` branch. The validator requires Python 3.11+ and `sh`; Windows users can install [Git for Windows](https://gitforwindows.org/) and make its shell available. It sets `RUMBLE_SMOKE=1`, invokes each individual bot's `.sh` launcher and requires a successful exit with the marker within 20 seconds. This verifies startup rather than a server handshake. Commit each `.sh` with executable mode `100755`, using `git update-index --chmod=+x` after staging it. Team entries contain JSON only and skip individual-bot launcher checks. diff --git a/scripts/validate_bot.py b/scripts/validate_bot.py index e84d9c4..cb386bc 100644 --- a/scripts/validate_bot.py +++ b/scripts/validate_bot.py @@ -104,6 +104,17 @@ def bot_directories(root: Path) -> list[tuple[str, Path]]: return result +def check_line_endings(directory: Path) -> None: + """Reject Windows line endings: the catalog holds Unix line endings, and a launcher script with CRLF cannot run under sh.""" + for path in sorted(directory.rglob("*")): + if not path.is_file() or "__pycache__" in path.parts: + continue + data = path.read_bytes() + if b"\0" not in data and b"\r\n" in data: + relative = path.relative_to(directory).as_posix() + raise ValidationError(f"{directory}: {relative} has Windows line endings (CRLF); convert it to Unix line endings (LF)") + + 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") @@ -116,6 +127,7 @@ def validate_bot(platform_key: str, directory: Path, *, smoke: bool) -> Bot: 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))}") + check_line_endings(directory) if "teamMembers" in config: members = config["teamMembers"] if not isinstance(members, list) or len(members) != 2 or not all(isinstance(member, str) and member for member in members): diff --git a/templates/README.md b/templates/README.md index 5b637f7..2129ae1 100644 --- a/templates/README.md +++ b/templates/README.md @@ -65,6 +65,8 @@ The validator checks the whole catalog checkout, so its existing entries' runtim Initial Java and NuGet downloads require network access. If a first download exceeds the smoke timeout, run the launcher once with `RUMBLE_SMOKE=1` to populate its external cache, then rerun validation. Preserve the smoke branch when editing your strategy; normal battle execution leaves `RUMBLE_SMOKE` unset. +Use Unix line endings (LF) in every file of the bot. The repository sets this for Git, so staging with Git normally takes care of it, but a file uploaded through the web page or written by a tool is not changed, and the validator rejects a file with Windows line endings (CRLF). A launcher script with them also cannot run under `sh`. + Stage the bot and set the Unix launcher's executable bit in Git on every platform: ```shell diff --git a/tests/test_templates.py b/tests/test_templates.py index ca35c3d..be1b80b 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -23,7 +23,8 @@ def copy_template(root: Path, language: str, name: str) -> Path: shutil.copytree(REPOSITORY / "templates" / language / "StarterBot", destination) for path in list(destination.rglob("*")): if path.is_file(): - path.write_text(path.read_text(encoding="utf-8").replace("StarterBot", name), encoding="utf-8") + # Bytes, not text: writing text on Windows would turn every line ending into a Windows line ending. + path.write_bytes(path.read_bytes().replace(b"StarterBot", name.encode("utf-8"))) if "StarterBot" in path.name: path.rename(path.with_name(path.name.replace("StarterBot", name))) return destination @@ -68,7 +69,7 @@ def test_RBC004_IntegrationPositive_team_template_needs_only_json_and_resolves_t team = self.root / "bots" / "python" / "MyTwins" team.mkdir() source = REPOSITORY / "templates" / "team" / "StarterTeam" / "StarterTeam.json" - (team / "MyTwins.json").write_text(source.read_text().replace("StarterTeam", "MyTwins"), encoding="utf-8") + (team / "MyTwins.json").write_bytes(source.read_bytes().replace(b"StarterTeam", b"MyTwins")) self.assertEqual(["MyTwins.json"], [path.name for path in team.iterdir()]) result = self.validate("--generate") self.assertEqual(0, result.returncode, result.stderr) diff --git a/tests/test_validate_bot.py b/tests/test_validate_bot.py index b186f8f..c5f7772 100644 --- a/tests/test_validate_bot.py +++ b/tests/test_validate_bot.py @@ -133,7 +133,7 @@ def test_invalid_license_is_rejected(self) -> None: 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") + source_path.write_bytes(source_path.read_bytes() + b"\n# changed\n") # bytes: write_text would give Windows line endings result = self.run_validator() self.assertNotEqual(0, result.returncode) self.assertIn("without increasing its version", result.stderr) @@ -262,6 +262,31 @@ def test_non_executable_boot_script_is_rejected(self) -> None: self.assertNotEqual(0, result.returncode) self.assertIn("executable bit", result.stderr) + def test_windows_line_endings_in_a_launcher_are_rejected_with_a_plain_message(self) -> None: + script = self.root / "bots" / "python" / "Orbit" / "Orbit.sh" + script.write_bytes(script.read_bytes().replace(b"\n", b"\r\n")) + result = self.run_validator() + self.assertNotEqual(0, result.returncode) + self.assertIn("Orbit.sh has Windows line endings (CRLF)", result.stderr) + self.assertIn("convert it to Unix line endings (LF)", result.stderr) + + def test_windows_line_endings_in_a_source_file_or_the_configuration_are_rejected(self) -> None: + for relative in ("src/Orbit.py", "Orbit.json"): + with self.subTest(relative): + path = self.root / "bots" / "python" / "Orbit" / relative + original = path.read_bytes() + path.write_bytes(original.replace(b"\n", b"\r\n") if b"\n" in original else original + b"\r\n") + result = self.run_validator() + self.assertNotEqual(0, result.returncode) + self.assertIn(f"{relative} has Windows line endings", result.stderr) + path.write_bytes(original) + + def test_unix_line_endings_are_accepted(self) -> None: + for path in (self.root / "bots" / "python" / "Orbit").rglob("*"): + if path.is_file(): + self.assertNotIn(b"\r\n", path.read_bytes(), str(path)) + self.assertEqual(0, self.run_validator().returncode) + def test_RBC004_IntegrationNegative_duplicate_bot_names_across_platforms_are_rejected(self) -> None: self.add_java_bot("Orbit")