diff --git a/.github/ISSUE_TEMPLATE/unsupported_gml_api.yml b/.github/ISSUE_TEMPLATE/unsupported_gml_api.yml index e39600f..2cbe415 100644 --- a/.github/ISSUE_TEMPLATE/unsupported_gml_api.yml +++ b/.github/ISSUE_TEMPLATE/unsupported_gml_api.yml @@ -39,6 +39,6 @@ body: attributes: label: Versions description: GM2Godot commit or release, GameMaker version, Godot version, and target platform. - placeholder: "GM2Godot 0.7.52, GameMaker LTS 2026, Godot 4.7.1, Windows" + placeholder: "GM2Godot 0.7.53, GameMaker LTS 2026, Godot 4.7.1, Windows" validations: required: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d11dd99..fc8f580 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -165,6 +165,7 @@ jobs: timeout-minutes: 20 env: PIP_CONFIG_FILE: nul + GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE: '1' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -234,6 +235,64 @@ jobs: tests.test_included_files.TestIncludedFilesManagedRootTransaction tests.test_included_files.TestIncludedFilesConverterOutputContainment + windows-included-files-scale: + runs-on: windows-2025 + timeout-minutes: 20 + env: + PIP_CONFIG_FILE: nul + GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12.10' + architecture: x64 + + - name: Install and verify test dependencies + shell: bash + run: | + venv_dir="$(cygpath -u "$RUNNER_TEMP")/gm2godot-included-files-scale-windows-venv" + if [ -e "$venv_dir" ] || [ -L "$venv_dir" ]; then + echo "::error::Refusing to reuse dependency environment: $venv_dir" >&2 + exit 1 + fi + python -m venv "$venv_dir" + venv_bin="$venv_dir/Scripts" + venv_native="$(cygpath -w "$venv_dir")" + venv_path_native="$(cygpath -w "$venv_bin")" + github_env_file="$(cygpath -u "$GITHUB_ENV")" + github_path_file="$(cygpath -u "$GITHUB_PATH")" + printf 'VIRTUAL_ENV=%s\n' "$venv_native" >> "$github_env_file" + printf '%s\n' "$venv_path_native" >> "$github_path_file" + export VIRTUAL_ENV="$venv_native" + export PATH="$venv_bin:$PATH" + hash -r + python -c 'import os, pathlib, sys; raise SystemExit(0 if pathlib.Path(sys.prefix).resolve() == pathlib.Path(os.environ["VIRTUAL_ENV"]).resolve() else 1)' + python -m pip --isolated --disable-pip-version-check --no-input install --no-cache-dir --only-binary=:all: \ + --constraint constraints/requirements-windows-py312.txt \ + pip==26.1.2 + python -m pip --isolated --disable-pip-version-check --no-input install --no-cache-dir --only-binary=:all: \ + --constraint constraints/requirements-windows-py312.txt \ + -r requirements.txt + python scripts/verify_dependency_environment.py \ + --constraint constraints/requirements-windows-py312.txt \ + --mode subset \ + --require pip \ + --require Pillow \ + --require markdown2 \ + --require requests \ + --require PySide6 \ + --expected-python 3.12.10 \ + --expected-platform win32 \ + --expected-machine AMD64 \ + --expected-pip 26.1.2 \ + --output "$RUNNER_TEMP/included-files-scale-windows-dependencies.json" + + - name: Run native Windows Included Files scale gate + run: python -m scripts.run_windows_included_files_scale_gate + windows-managed-output-crash-recovery: runs-on: windows-2025 timeout-minutes: 20 diff --git a/CHANGELOG.md b/CHANGELOG.md index ed729ad..045752e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.7.53 - 2026-08-03 + +- Split the native Windows 10,000-entry Included Files stress test from the broader artifact-transaction suite so both retain substantial margin under the unchanged 20-minute timeout. +- Kept ordinary and local unittest discovery running the scale test while allowing only its paired GitHub Actions job to skip that exact case. +- Added a fail-closed dedicated runner that requires exactly one executed passing scale test with zero skips, plus workflow-policy coverage for the pinned Windows runner, Python, dependency constraints, timeout, flags, and locked invocation. + ## 0.7.52 - 2026-07-23 - Extracted dependency-only `shared_models`, `expression_models`, and `result_models` owners for cross-phase tokens/context/static metadata, the complete expression AST union, and preprocessing/diagnostic/source-map/transpile results. diff --git a/README.md b/README.md index b5599e3..d7e4f59 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Version 0.7.51 freezes the current GML transpiler facade and phase-boundary base Version 0.7.52 extracts dependency-only typed owners for shared GML phase data, the complete expression AST union, and preprocessing/source-map/transpile results. Every phase and converter now consumes non-underscore model exports; only the frozen top-level facade retains private model aliases for the later #820 cleanup. Dataclass shape and mutability, errors, serialization, generated output, diagnostics, and supported facade identities/signatures remain unchanged. +Version 0.7.53 keeps the native Windows artifact-transaction suite and the 10,000-entry Included Files publish/recovery stress gate in separate jobs under the same 20-minute timeout. The scale test remains part of ordinary local and full-suite discovery; only the paired broader Windows job may skip it, while a fail-closed dedicated runner requires the exact case to be the sole collected test and to execute successfully without a skip. + ## What GM2Godot Is and Isn't **GM2Godot is:** @@ -82,7 +84,7 @@ The full compatibility roadmap lives in [`todo-list/`](todo-list/README.md). It ## Releases -Current source version: `0.7.52`. +Current source version: `0.7.53`. Downloadable releases include Windows (`.exe`), macOS (`.dmg` with `.app`), and Linux binaries. You can also run from source on Windows, macOS, and Linux. The packaged Linux artifact is validated on Ubuntu 24.04 x86_64. Its glibc 2.39 requirement is necessary but does not make other distributions a validated target; they must also supply compatible system, OpenGL/EGL, and X11 libraries. The reviewed Linux package manifest installs Ubuntu's `libegl1` and `libgl1` providers for QtGui together with the required XCB client libraries. The release job rejects unresolved-library warnings, extracts the final ZIP, and proves that its GUI reaches the event loop through the real `qxcb` platform under Xvfb before upload. diff --git a/docs/wiki/Compatibility-and-Limitations.md b/docs/wiki/Compatibility-and-Limitations.md index 6a34224..8e77a73 100644 --- a/docs/wiki/Compatibility-and-Limitations.md +++ b/docs/wiki/Compatibility-and-Limitations.md @@ -1,8 +1,8 @@ # Compatibility and Limitations -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 [Home](Home) · [Quick Start Conversion](Quick-Start-Conversion) · [Diagnostics and Troubleshooting](Diagnostics-and-Troubleshooting) diff --git a/docs/wiki/Contributing-and-Testing.md b/docs/wiki/Contributing-and-Testing.md index 6cf0aab..b888ddc 100644 --- a/docs/wiki/Contributing-and-Testing.md +++ b/docs/wiki/Contributing-and-Testing.md @@ -1,8 +1,8 @@ # Contributing and Testing -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 This page is the short contributor route map. The repository's [CONTRIBUTING.md](https://github.com/Infiland/GM2Godot/blob/main/CONTRIBUTING.md) and `AGENTS.md` remain authoritative for development rules. @@ -188,6 +188,8 @@ Included Files transaction changes must retain the subprocess hard-exit recovery The publication tests stop the child process at every forward transaction phase from the staged journal through commit-marker retirement, then require both format-v1 and format-v2 recovery to select one complete generation. The size tests require byte-exact preflight before payload staging and keep the larger record for a changed 10,000-file generation below the unchanged 16 MiB cap. The two cleanup tests independently hard-exit after quarantine or removal for owned backup, staging, stable-record, and temporary-record state. Run the native Windows Included Files workflow when changing lock, move, junction, read-only, or cleanup behavior; modeled `os.name` tests are not a substitute for NTFS and Win32 coverage. Preserve the public `res://included_files/` and registry paths, reject unknown reserved-path state, and keep the documented prohibition on conversion alongside a live game or non-cooperating writer. +Native Windows CI keeps the broad `windows-artifact-transactions` suite separate from `windows-included-files-scale`. The broad job may set `GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE=1` only under GitHub Actions; the dedicated job sets `GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE=1` and invokes `python -m scripts.run_windows_included_files_scale_gate` from the repository root. That fail-closed runner loads only `test_ten_thousand_entry_compact_records_publish_and_recover_below_cap`, requires exactly one collected and executed passing test, and rejects skips, failures, errors, expected failures, unexpected successes, and fixture exits. Workflow policy locks the dedicated step inventory and forbids conditional or continue-on-error execution. Simultaneous require/skip flags fail closed. Both jobs keep the pinned Windows 2025, CPython 3.12.10, dependency constraints, and 20-minute timeout. Never copy the skip flag into local commands or the dedicated scale job. + Worker-scheduling changes must also retain the deterministic 10,000-source submission bound, changed/unchanged failure and cancellation admission checks, and cross-worker output equivalence: ```bash diff --git a/docs/wiki/Diagnostics-and-Troubleshooting.md b/docs/wiki/Diagnostics-and-Troubleshooting.md index 0cb5ac9..52327f3 100644 --- a/docs/wiki/Diagnostics-and-Troubleshooting.md +++ b/docs/wiki/Diagnostics-and-Troubleshooting.md @@ -1,8 +1,8 @@ # Diagnostics and Troubleshooting -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 [Home](Home) · [Quick Start Conversion](Quick-Start-Conversion) · [Compatibility and Limitations](Compatibility-and-Limitations) diff --git a/docs/wiki/Generated-Project-and-Runtime.md b/docs/wiki/Generated-Project-and-Runtime.md index 4410203..34a068a 100644 --- a/docs/wiki/Generated-Project-and-Runtime.md +++ b/docs/wiki/Generated-Project-and-Runtime.md @@ -1,8 +1,8 @@ # Generated Project and Runtime -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 [Home](Home) · [Installation](Installation) · [Quick start](Quick-Start-Conversion) · [Compatibility](Compatibility-and-Limitations) · [Diagnostics](Diagnostics-and-Troubleshooting) · [Contributing](Contributing-and-Testing) diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5e817b4..9f331f6 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,8 +1,8 @@ # GM2Godot Documentation -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 GM2Godot converts supported GameMaker source projects and GML into editable Godot projects. It combines asset conversion, a GML-to-GDScript transpiler, generated runtime helpers, compatibility diagnostics, and headless Godot validation. It is a migration aid, not a promise of automatic one-to-one gameplay parity. @@ -21,7 +21,7 @@ Maintainers should also read [Release and Wiki Maintenance](Maintainer-Release-a This documentation set describes: -- GM2Godot 0.7.52; +- GM2Godot 0.7.53; - GameMaker LTS 2026 source projects in the GMS2 runtime family; and - Godot 4.7.1 output and validation, pinned in CI as `4.7.1.stable.official.a13da4feb`. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 61122dd..2282911 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -1,8 +1,8 @@ # Installation -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 Use a packaged release for the desktop interface, or run from source when you also need the headless CLI. The current packaging and dependency details live in the repository's [release workflow](https://github.com/Infiland/GM2Godot/blob/main/.github/workflows/release.yml), [`requirements.txt`](https://github.com/Infiland/GM2Godot/blob/main/requirements.txt), and [native dependency-lock workflow](https://github.com/Infiland/GM2Godot/blob/main/.github/workflows/dependency-locks.yml). @@ -46,7 +46,7 @@ On Windows, run `Get-FileHash -Algorithm SHA256 .\GM2Godot-windows.zip` in Power The packaged builds are produced as windowed applications. For the CLI commands in this Wiki, use a source installation. -After launch, confirm that the title bar or **Help → About GM2Godot** shows version `0.7.52`. Click the version in the bottom information bar to browse the ten newest release changelogs; **Show more** appends the next ten. +After launch, confirm that the title bar or **Help → About GM2Godot** shows version `0.7.53`. Click the version in the bottom information bar to browse the ten newest release changelogs; **Show more** appends the next ten. ## Run from source @@ -133,6 +133,6 @@ python main.py --version python main.py list-converters ``` -The first command should print `GM2Godot 0.7.52`; the second should list the conversion groups and the exact converter keys accepted by `--only`. The same CLI is also available through `python -m src.cli`. +The first command should print `GM2Godot 0.7.53`; the second should list the conversion groups and the exact converter keys accepted by `--only`. The same CLI is also available through `python -m src.cli`. Continue with [Quick Start Conversion](Quick-Start-Conversion). If launch or dependency setup fails, see [Diagnostics and Troubleshooting](Diagnostics-and-Troubleshooting). diff --git a/docs/wiki/Maintainer-Release-and-Wiki.md b/docs/wiki/Maintainer-Release-and-Wiki.md index 4e74136..2047066 100644 --- a/docs/wiki/Maintainer-Release-and-Wiki.md +++ b/docs/wiki/Maintainer-Release-and-Wiki.md @@ -1,8 +1,8 @@ # Release and Wiki Maintenance -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 This page documents the current maintainer path for a versioned release and for publishing the reviewed Wiki sources. It does not replace branch protection or repository settings. diff --git a/docs/wiki/Quick-Start-Conversion.md b/docs/wiki/Quick-Start-Conversion.md index 5497fe6..47f5a99 100644 --- a/docs/wiki/Quick-Start-Conversion.md +++ b/docs/wiki/Quick-Start-Conversion.md @@ -1,8 +1,8 @@ # Quick Start Conversion -> **Applies to:** GM2Godot 0.7.52 · GameMaker LTS 2026 · Godot 4.7.1 +> **Applies to:** GM2Godot 0.7.53 · GameMaker LTS 2026 · Godot 4.7.1 > -> **Last reviewed:** 2026-07-23 +> **Last reviewed:** 2026-08-03 This guide covers a first conversion through either the desktop interface or the source CLI. GM2Godot converts source projects, so select the GameMaker project root that contains the `.yyp` file—not an exported or compiled game. diff --git a/scripts/run_windows_included_files_scale_gate.py b/scripts/run_windows_included_files_scale_gate.py new file mode 100644 index 0000000..8710f72 --- /dev/null +++ b/scripts/run_windows_included_files_scale_gate.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import sys +import unittest +from typing import TextIO + + +EXACT_TEST_NAME = ( + "tests.test_included_files.TestIncludedFilesManagedRootTransaction." + "test_ten_thousand_entry_compact_records_publish_and_recover_below_cap" +) +REQUIRE_ENVIRONMENT_VARIABLE = ( + "GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE" +) +SKIP_ENVIRONMENT_VARIABLE = "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE" + + +def run_exact_suite( + suite: unittest.TestSuite, + *, + stream: TextIO, +) -> int: + collected = suite.countTestCases() + if collected != 1: + print( + "The native Windows Included Files scale gate must collect exactly " + f"one test; collected {collected}.", + file=stream, + ) + return 2 + + result = unittest.TextTestRunner(stream=stream, verbosity=2).run(suite) + invalid_outcomes = ( + result.testsRun != 1 + or bool(result.skipped) + or bool(result.failures) + or bool(result.errors) + or bool(result.expectedFailures) + or bool(result.unexpectedSuccesses) + or not result.wasSuccessful() + ) + if invalid_outcomes: + print( + "The native Windows Included Files scale gate requires exactly one " + "executed passing test with zero skips, failures, errors, expected " + "failures, or unexpected successes.", + file=stream, + ) + return 1 + return 0 + + +def main() -> int: + if os.environ.get("GITHUB_ACTIONS") != "true": + print( + "The native Windows Included Files scale gate runner may only run " + "in GitHub Actions.", + file=sys.stderr, + ) + return 2 + if os.environ.get(REQUIRE_ENVIRONMENT_VARIABLE) != "1": + print( + f"{REQUIRE_ENVIRONMENT_VARIABLE}=1 is required.", + file=sys.stderr, + ) + return 2 + if os.environ.get(SKIP_ENVIRONMENT_VARIABLE) == "1": + print( + f"{SKIP_ENVIRONMENT_VARIABLE}=1 conflicts with the required gate.", + file=sys.stderr, + ) + return 2 + + try: + suite = unittest.defaultTestLoader.loadTestsFromName(EXACT_TEST_NAME) + return run_exact_suite(suite, stream=sys.stderr) + except SystemExit as error: + print( + "The native Windows Included Files scale gate raised an unexpected " + f"SystemExit({error.code!r}); refusing a successful process exit.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/version.py b/src/version.py index 9f123a2..80d9d0b 100644 --- a/src/version.py +++ b/src/version.py @@ -1,4 +1,4 @@ -VERSION = "0.7.52" +VERSION = "0.7.53" def get_version(): diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 92a4f9e..e597292 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -124,6 +124,57 @@ "--no-cache-dir", "--only-binary=:all:", ) +WINDOWS_INCLUDED_FILES_SCALE_GATE_RUNNER = ( + "python -m scripts.run_windows_included_files_scale_gate" +) +WINDOWS_INCLUDED_FILES_SCALE_GATE_STEP = ( + " - name: Run native Windows Included Files scale gate\n" + f" run: {WINDOWS_INCLUDED_FILES_SCALE_GATE_RUNNER}" +) +_WINDOWS_SCALE_FORBIDDEN_YAML_KEY = re.compile( + r"(?m)^[ \t]*(?:if|continue-on-error|defaults|" + r"['\"]if['\"]|['\"]continue-on-error['\"]|" + r"['\"]defaults['\"])[ \t]*:" +) +_WORKFLOW_DEFAULTS_KEY = re.compile( + r"(?m)^(?:defaults|['\"]defaults['\"])[ \t]*:" +) + + +def _windows_scale_gate_command_policy_errors( + scale_job: str, + workflow_content: str, +) -> tuple[str, ...]: + errors: list[str] = [] + runner = WINDOWS_INCLUDED_FILES_SCALE_GATE_RUNNER + runner_module = "run_windows_included_files_scale_gate" + lowered_job = scale_job.lower() + + if scale_job.count(runner) != 1 or workflow_content.count(runner) != 1: + errors.append("the exact runner command must occur once") + if scale_job.count(runner_module) != 1: + errors.append("the runner module must occur once") + if "unittest" in lowered_job or "test_included_files" in lowered_job: + errors.append("broader unittest selectors are forbidden") + if _WINDOWS_SCALE_FORBIDDEN_YAML_KEY.search(scale_job): + errors.append("conditional, non-fatal, or custom default execution is forbidden") + if _WORKFLOW_DEFAULTS_KEY.search(workflow_content): + errors.append("workflow-wide run defaults are forbidden") + if scale_job.count("\n run:") != 2: + errors.append("the scale job must contain exactly two run steps") + if scale_job.count("\n - uses:") != 1: + errors.append("the scale job must contain exactly one unnamed action step") + if scale_job.count("\n - name:") != 3: + errors.append("the scale job must contain exactly three named steps") + + runner_marker = " - name: Run native Windows Included Files scale gate" + if runner_marker not in scale_job: + errors.append("the dedicated runner step is missing") + elif scale_job[scale_job.index(runner_marker):].rstrip() != ( + WINDOWS_INCLUDED_FILES_SCALE_GATE_STEP + ): + errors.append("the dedicated runner step must retain its exact shape") + return tuple(errors) def _logical_commands(path: Path) -> tuple[tuple[int, str], ...]: @@ -3697,6 +3748,14 @@ def test_native_workflow_installs_use_fresh_selected_venvs(self) -> None: "gm2godot-tests-windows-venv", "nul", ), + ( + "tests.yml", + "windows-included-files-scale", + "Install and verify test dependencies", + "windows", + "gm2godot-included-files-scale-windows-venv", + "nul", + ), ( "tests.yml", "windows-managed-output-crash-recovery", @@ -3721,7 +3780,7 @@ def test_native_workflow_installs_use_fresh_selected_venvs(self) -> None: "pathlib.Path(os.environ[\"VIRTUAL_ENV\"]).resolve() else 1)'" ) - self.assertEqual(len(install_jobs), 10) + self.assertEqual(len(install_jobs), 11) for ( workflow_name, job_name, @@ -3910,8 +3969,8 @@ def test_pip_install_inventory_is_constrained_and_uses_module_entrypoints( ): 1, (MACOS_CONSTRAINT, (f"pip=={PIP_VERSION}",)): 1, (MACOS_CONSTRAINT, ("-r", "requirements.txt")): 1, - (WINDOWS_CONSTRAINT, (f"pip=={PIP_VERSION}",)): 2, - (WINDOWS_CONSTRAINT, ("-r", "requirements.txt")): 2, + (WINDOWS_CONSTRAINT, (f"pip=={PIP_VERSION}",)): 3, + (WINDOWS_CONSTRAINT, ("-r", "requirements.txt")): 3, } ), "build_macos.sh": Counter( @@ -4017,7 +4076,7 @@ def test_pip_install_inventory_is_constrained_and_uses_module_entrypoints( self.assertEqual(actual_install_files, expected_install_files) self.assertEqual(actual_profiles, expected_profiles) - self.assertEqual(non_dependency_lock_command_count, 25) + self.assertEqual(non_dependency_lock_command_count, 27) self.assertEqual(dependency_lock_command_count, 6) def test_pip_inventory_classifies_continuations_and_rejects_escape_hatches( @@ -4946,13 +5005,18 @@ def test_unit_workflow_runs_artifact_transactions_on_windows(self) -> None: content = workflow.read_text(encoding="utf-8") windows_job = content[ content.index(" windows-artifact-transactions:"): - content.index(" windows-managed-output-crash-recovery:") + content.index(" windows-included-files-scale:") ] self.assertIn("runs-on: windows-2025", windows_job) + self.assertIn("timeout-minutes: 20", windows_job) self.assertIn("python-version: '3.12.10'", windows_job) self.assertIn("architecture: x64", windows_job) self.assertIn("shell: bash", windows_job) + self.assertIn( + "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE: '1'", + windows_job, + ) self.assertIn( f"python {PIP_HARDENED_INSTALL_FRAGMENT} --no-cache-dir --only-binary=:all: \\\n" f" --constraint {WINDOWS_CONSTRAINT} \\\n" @@ -4973,6 +5037,113 @@ def test_unit_workflow_runs_artifact_transactions_on_windows(self) -> None: with self.subTest(module=module): self.assertIn(module, windows_job) + def test_unit_workflow_shards_native_windows_included_files_scale_gate( + self, + ) -> None: + workflow = PROJECT_ROOT / ".github" / "workflows" / "tests.yml" + content = workflow.read_text(encoding="utf-8") + normal_job = content[ + content.index(" windows-artifact-transactions:"): + content.index(" windows-included-files-scale:") + ] + scale_job = content[ + content.index(" windows-included-files-scale:"): + content.index(" windows-managed-output-crash-recovery:") + ] + skip_variable = "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE" + require_variable = ( + "GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE" + ) + exact_runner = WINDOWS_INCLUDED_FILES_SCALE_GATE_RUNNER + + self.assertEqual(normal_job.count(skip_variable), 1) + self.assertEqual(content.count(skip_variable), 1) + self.assertNotIn(skip_variable, scale_job) + self.assertNotIn(require_variable, normal_job) + self.assertEqual(scale_job.count(require_variable), 1) + self.assertEqual(content.count(require_variable), 1) + self.assertIn(f"{require_variable}: '1'", scale_job) + self.assertNotIn(exact_runner, normal_job) + self.assertEqual( + _windows_scale_gate_command_policy_errors(scale_job, content), + (), + ) + self.assertIn("runs-on: windows-2025", scale_job) + self.assertIn("timeout-minutes: 20", scale_job) + self.assertIn("PIP_CONFIG_FILE: nul", scale_job) + self.assertIn("python-version: '3.12.10'", scale_job) + self.assertIn("architecture: x64", scale_job) + self.assertIn("shell: bash", scale_job) + self.assertIn( + f"python {PIP_HARDENED_INSTALL_FRAGMENT} --no-cache-dir --only-binary=:all: \\\n" + f" --constraint {WINDOWS_CONSTRAINT} \\\n" + " -r requirements.txt", + scale_job, + ) + self.assertIn( + "gm2godot-included-files-scale-windows-venv", + scale_job, + ) + self.assertIn( + "$RUNNER_TEMP/included-files-scale-windows-dependencies.json", + scale_job, + ) + + def test_unit_workflow_scale_gate_policy_rejects_bypass_mutations( + self, + ) -> None: + workflow = PROJECT_ROOT / ".github" / "workflows" / "tests.yml" + content = workflow.read_text(encoding="utf-8") + scale_job = content[ + content.index(" windows-included-files-scale:"): + content.index(" windows-managed-output-crash-recovery:") + ] + runner_line = ( + f" run: {WINDOWS_INCLUDED_FILES_SCALE_GATE_RUNNER}\n" + ) + dependency_command = ( + " python scripts/verify_dependency_environment.py \\\n" + ) + mutations = { + "quoted-if": scale_job.replace( + runner_line, + runner_line + ' "if": ${{ false }}\n', + ), + "quoted-continue-on-error": scale_job.replace( + runner_line, + runner_line + ' "continue-on-error": true\n', + ), + "custom-shell": scale_job.replace( + runner_line, + runner_line + " shell: cmd\n", + ), + "ignored-exit": scale_job.replace( + runner_line, + runner_line.rstrip("\n") + " || true\n", + ), + "slash-module-selector": scale_job.replace( + dependency_command, + " PYTHONPATH=. python tests/test_included_files.py\n" + + dependency_command, + ), + "duplicate-runner-module": scale_job.replace( + dependency_command, + " python -m scripts.run_windows_included_files_scale_gate\n" + + dependency_command, + ), + } + + for case, mutated_job in mutations.items(): + with self.subTest(case=case): + self.assertNotEqual(mutated_job, scale_job) + mutated_content = content.replace(scale_job, mutated_job) + self.assertTrue( + _windows_scale_gate_command_policy_errors( + mutated_job, + mutated_content, + ) + ) + def test_unit_workflow_runs_native_windows_crash_matrix_separately( self, ) -> None: diff --git a/tests/test_included_files.py b/tests/test_included_files.py index 35e1049..77eafeb 100644 --- a/tests/test_included_files.py +++ b/tests/test_included_files.py @@ -2563,9 +2563,112 @@ def test_changed_ten_thousand_entry_preflight_stays_below_cap( included_files_module._INCLUDED_FILES_RECOVERY_RECORD_MAX_BYTES, ) + @staticmethod + def _enforce_windows_included_files_scale_gate_environment() -> None: + require_gate = ( + os.environ.get( + "GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE" + ) + == "1" + ) + skip_gate = ( + os.environ.get( + "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE" + ) + == "1" + ) + if require_gate: + if os.environ.get("GITHUB_ACTIONS") != "true": + raise AssertionError( + "The Included Files scale gate may only be required by " + "GitHub Actions" + ) + if skip_gate: + raise AssertionError( + "The required Included Files scale gate cannot be skipped" + ) + if skip_gate: + if os.environ.get("GITHUB_ACTIONS") != "true": + raise AssertionError( + "The Included Files scale gate may only be skipped by its " + "paired GitHub Actions job" + ) + raise unittest.SkipTest( + "covered by the dedicated native Windows Included Files " + "scale job" + ) + + def test_scale_gate_skip_flag_fails_outside_github_actions(self) -> None: + with ( + patch.dict( + os.environ, + { + "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE": "1", + "GITHUB_ACTIONS": "", + }, + ), + self.assertRaisesRegex( + AssertionError, + "may only be skipped by its paired GitHub Actions job", + ), + ): + self._enforce_windows_included_files_scale_gate_environment() + + def test_scale_gate_skip_flag_skips_inside_github_actions(self) -> None: + with ( + patch.dict( + os.environ, + { + "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE": "1", + "GITHUB_ACTIONS": "true", + }, + ), + self.assertRaisesRegex( + unittest.SkipTest, + "dedicated native Windows Included Files scale job", + ), + ): + self._enforce_windows_included_files_scale_gate_environment() + + def test_scale_gate_require_flag_rejects_skip(self) -> None: + with ( + patch.dict( + os.environ, + { + "GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE": "1", + "GM2GODOT_SKIP_WINDOWS_INCLUDED_FILES_SCALE_GATE": "1", + "GITHUB_ACTIONS": "true", + }, + ), + self.assertRaisesRegex( + AssertionError, + "required Included Files scale gate cannot be skipped", + ), + ): + self._enforce_windows_included_files_scale_gate_environment() + + def test_scale_gate_require_flag_fails_outside_github_actions( + self, + ) -> None: + with ( + patch.dict( + os.environ, + { + "GM2GODOT_REQUIRE_WINDOWS_INCLUDED_FILES_SCALE_GATE": "1", + "GITHUB_ACTIONS": "", + }, + ), + self.assertRaisesRegex( + AssertionError, + "may only be required by GitHub Actions", + ), + ): + self._enforce_windows_included_files_scale_gate_environment() + def test_ten_thousand_entry_compact_records_publish_and_recover_below_cap( self, ) -> None: + self._enforce_windows_included_files_scale_gate_environment() entry_count = 10_000 for index in range(entry_count): with open( diff --git a/tests/test_version.py b/tests/test_version.py index a50cdbf..c27b3a9 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -11,8 +11,8 @@ class TestVersion(unittest.TestCase): - def test_release_version_is_0_7_52(self) -> None: - self.assertEqual(get_version(), "0.7.52") + def test_release_version_is_0_7_53(self) -> None: + self.assertEqual(get_version(), "0.7.53") def test_release_surfaces_match_source_version(self) -> None: changelog = (PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") diff --git a/tests/test_windows_included_files_scale_gate_runner.py b/tests/test_windows_included_files_scale_gate_runner.py new file mode 100644 index 0000000..1cc6df8 --- /dev/null +++ b/tests/test_windows_included_files_scale_gate_runner.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from io import StringIO +import os +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import ANY, patch + +from scripts import run_windows_included_files_scale_gate as scale_gate + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class TestWindowsIncludedFilesScaleGateRunner(unittest.TestCase): + @staticmethod + def _suite(test_case: unittest.TestCase) -> unittest.TestSuite: + return unittest.TestSuite((test_case,)) + + def _run_case(self, test_case: unittest.TestCase) -> tuple[int, str]: + stream = StringIO() + status = scale_gate.run_exact_suite( + self._suite(test_case), + stream=stream, + ) + return status, stream.getvalue() + + def test_target_is_the_exact_ten_thousand_entry_case(self) -> None: + self.assertEqual( + scale_gate.EXACT_TEST_NAME, + "tests.test_included_files.TestIncludedFilesManagedRootTransaction." + "test_ten_thousand_entry_compact_records_publish_and_recover_below_cap", + ) + + def test_exact_passing_case_succeeds(self) -> None: + status, output = self._run_case(unittest.FunctionTestCase(lambda: None)) + + self.assertEqual(status, 0) + self.assertIn("Ran 1 test", output) + self.assertIn("OK", output) + + def test_empty_or_duplicate_collection_fails_before_execution(self) -> None: + for count in (0, 2): + with self.subTest(count=count): + suite = unittest.TestSuite( + unittest.FunctionTestCase(lambda: None) + for _index in range(count) + ) + stream = StringIO() + + status = scale_gate.run_exact_suite(suite, stream=stream) + + self.assertEqual(status, 2) + self.assertIn( + f"must collect exactly one test; collected {count}", + stream.getvalue(), + ) + + def test_decorator_skip_is_a_gate_failure(self) -> None: + class DecoratorSkippedCase(unittest.TestCase): + @unittest.skip("injected decorator skip") + def runTest(self) -> None: + self.fail("the skipped body must not execute") + + status, output = self._run_case(DecoratorSkippedCase()) + + self.assertEqual(status, 1) + self.assertIn("skipped", output) + self.assertIn("requires exactly one executed passing test", output) + + def test_setup_skip_is_a_gate_failure(self) -> None: + class SetupSkippedCase(unittest.TestCase): + def setUp(self) -> None: + self.skipTest("injected setup skip") + + def runTest(self) -> None: + self.fail("the skipped body must not execute") + + status, output = self._run_case(SetupSkippedCase()) + + self.assertEqual(status, 1) + self.assertIn("injected setup skip", output) + self.assertIn("zero skips", output) + + def test_failure_and_error_are_gate_failures(self) -> None: + def fail() -> None: + raise AssertionError("injected failure") + + def error() -> None: + raise RuntimeError("injected error") + + for case, expected in ( + (unittest.FunctionTestCase(fail), "injected failure"), + (unittest.FunctionTestCase(error), "injected error"), + ): + with self.subTest(expected=expected): + status, output = self._run_case(case) + + self.assertEqual(status, 1) + self.assertIn(expected, output) + + def test_expected_failure_and_unexpected_success_are_gate_failures( + self, + ) -> None: + class ExpectedFailureCase(unittest.TestCase): + @unittest.expectedFailure + def runTest(self) -> None: + self.fail("injected expected failure") + + class UnexpectedSuccessCase(unittest.TestCase): + @unittest.expectedFailure + def runTest(self) -> None: + pass + + for case, expected in ( + (ExpectedFailureCase(), "expected failure"), + (UnexpectedSuccessCase(), "unexpected success"), + ): + with self.subTest(expected=expected): + status, output = self._run_case(case) + + self.assertEqual(status, 1) + self.assertIn(expected, output.lower()) + + def test_main_rejects_non_ci_missing_require_and_skip_conflict(self) -> None: + environments: tuple[tuple[dict[str, str], str], ...] = ( + ({}, "may only run in GitHub Actions"), + ({"GITHUB_ACTIONS": "true"}, "=1 is required"), + ( + { + "GITHUB_ACTIONS": "true", + scale_gate.REQUIRE_ENVIRONMENT_VARIABLE: "1", + scale_gate.SKIP_ENVIRONMENT_VARIABLE: "1", + }, + "conflicts with the required gate", + ), + ) + for environment, expected in environments: + with self.subTest(expected=expected), patch.dict( + os.environ, + environment, + clear=True, + ), patch("sys.stderr", new_callable=StringIO) as stderr: + status = scale_gate.main() + + self.assertEqual(status, 2) + self.assertIn(expected, stderr.getvalue()) + + def test_module_entrypoint_executes_fail_closed_runner(self) -> None: + environment = os.environ.copy() + environment.pop("GITHUB_ACTIONS", None) + environment.pop(scale_gate.REQUIRE_ENVIRONMENT_VARIABLE, None) + environment.pop(scale_gate.SKIP_ENVIRONMENT_VARIABLE, None) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "scripts.run_windows_included_files_scale_gate", + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + self.assertEqual(completed.returncode, 2) + self.assertEqual(completed.stdout, "") + self.assertIn("may only run in GitHub Actions", completed.stderr) + + def test_main_loads_only_exact_test_and_propagates_runner_status( + self, + ) -> None: + for runner_status in (0, 1, 2): + with self.subTest(runner_status=runner_status): + suite = self._suite(unittest.FunctionTestCase(lambda: None)) + with ( + patch.dict( + os.environ, + { + "GITHUB_ACTIONS": "true", + scale_gate.REQUIRE_ENVIRONMENT_VARIABLE: "1", + }, + clear=True, + ), + patch.object( + unittest.defaultTestLoader, + "loadTestsFromName", + return_value=suite, + ) as load_tests, + patch.object( + scale_gate, + "run_exact_suite", + return_value=runner_status, + ) as run_suite, + ): + status = scale_gate.main() + + self.assertEqual(status, runner_status) + load_tests.assert_called_once_with(scale_gate.EXACT_TEST_NAME) + run_suite.assert_called_once_with(suite, stream=ANY) + + def test_main_converts_fixture_system_exit_zero_to_failure(self) -> None: + class ExitFromClassFixtureCase(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + raise SystemExit(0) + + def runTest(self) -> None: + self.fail("the fixture must prevent execution") + + suite = self._suite(ExitFromClassFixtureCase()) + with ( + patch.dict( + os.environ, + { + "GITHUB_ACTIONS": "true", + scale_gate.REQUIRE_ENVIRONMENT_VARIABLE: "1", + }, + clear=True, + ), + patch.object( + unittest.defaultTestLoader, + "loadTestsFromName", + return_value=suite, + ), + patch("sys.stderr", new_callable=StringIO) as stderr, + ): + status = scale_gate.main() + + self.assertEqual(status, 1) + self.assertIn("unexpected SystemExit(0)", stderr.getvalue()) + self.assertIn("refusing a successful process exit", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main()