From 703bc5d64b82469a20830902c3074d08db4a9949 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 11 Oct 2026 10:53:16 +0200 Subject: [PATCH 1/2] Prepare 0.23.1 with recoverable setup and tested release assets --- .github/workflows/ci.yml | 31 ++++++++--- CHANGELOG.md | 8 ++- docs/compatibility.md | 21 ++++++++ docs/releasing.md | 40 ++++++++++++++ scripts/compatibility.py | 4 ++ scripts/doctor.py | 94 +++++++++++++++++++++++++++++++++ scripts/prepare_release.py | 87 ++++++++++++++++++++++++++++++ scripts/test_doctor.py | 41 ++++++++++++++ scripts/test_prepare_release.py | 37 +++++++++++++ scripts/test_windows_setup.ps1 | 60 +++++++++++++++++++++ scripts/windows_setup_tools.ps1 | 42 ++++++++++++--- v.mod | 2 +- 12 files changed, 450 insertions(+), 17 deletions(-) create mode 100644 docs/releasing.md create mode 100644 scripts/doctor.py create mode 100644 scripts/prepare_release.py create mode 100644 scripts/test_doctor.py create mode 100644 scripts/test_prepare_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6409590..0782d7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: id: compat shell: bash run: | - python scripts/test_distribution.py + python -m unittest discover -s scripts -p 'test_*.py' python scripts/compatibility.py --github-output "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -189,9 +189,9 @@ jobs: - name: Verify deterministic OpenCL headless run run: xvfb-run -a env TT_OPENCL_SMOKE=1 TT_REPLAY_UI_SMOKE=1 ./scripts/check.sh - name: Package Linux candidate - run: bash scripts/package_linux.sh torus-trooper-0.23.0-linux-x86_64.tar.gz + run: bash scripts/package_linux.sh torus-trooper-${{ steps.compat.outputs.project_version }}-linux-x86_64.tar.gz - name: Probe Linux package without host runtime libraries - run: xvfb-run -a sudo --preserve-env=DISPLAY,XAUTHORITY bash scripts/test_linux_package.sh torus-trooper-0.23.0-linux-x86_64.tar.gz + run: xvfb-run -a sudo --preserve-env=DISPLAY,XAUTHORITY bash scripts/test_linux_package.sh torus-trooper-${{ steps.compat.outputs.project_version }}-linux-x86_64.tar.gz - name: Probe package on Ubuntu 26.04 without installing GLFW run: | xvfb-run -a bash -c ' @@ -199,7 +199,7 @@ jobs: -e DISPLAY -e XAUTHORITY=/tmp/game-xauthority \ -v /tmp/.X11-unix:/tmp/.X11-unix:ro \ -v "$XAUTHORITY:/tmp/game-xauthority:ro" \ - -v "$PWD/torus-trooper-0.23.0-linux-x86_64.tar.gz:/candidate.tar.gz:ro" \ + -v "$PWD/torus-trooper-${{ steps.compat.outputs.project_version }}-linux-x86_64.tar.gz:/candidate.tar.gz:ro" \ ubuntu:26.04 bash -ec " apt-get update apt-get install -y --no-install-recommends mesa-vulkan-drivers @@ -213,7 +213,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: torus-trooper-linux-x86_64 - path: torus-trooper-0.23.0-linux-x86_64.tar.gz + path: torus-trooper-${{ steps.compat.outputs.project_version }}-linux-x86_64.tar.gz if-no-files-found: error windows-v-tests: @@ -314,7 +314,22 @@ jobs: $Destination = Join-Path $env:VULKAN_SDK 'Include\volk' New-Item -ItemType Directory -Force $Destination | Out-Null Copy-Item dependencies/volk/volk.h, dependencies/volk/volk.c $Destination + - name: Resolve native binary cache identity + id: native-cache + shell: pwsh + run: | + $Commit = (& git -C $env:VCPKG_INSTALLATION_ROOT rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Cannot identify vcpkg ports revision' } + "commit=$Commit" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + New-Item -ItemType Directory -Force (Join-Path $env:RUNNER_TEMP 'vcpkg-binary-cache') | Out-Null + - name: Cache ABI-checked native packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ runner.temp }}/vcpkg-binary-cache + key: windows-vcpkg-${{ steps.native-cache.outputs.commit }}-${{ hashFiles('compatibility.json') }} - name: Install GLFW and shader tools + env: + VCPKG_BINARY_SOURCES: clear;files,${{ runner.temp }}/vcpkg-binary-cache,readwrite shell: pwsh run: | vcpkg install glfw3:x64-mingw-static 'glslang[tools]:x64-windows' 'spirv-tools[tools]:x64-windows' @@ -336,10 +351,10 @@ jobs: shell: pwsh run: | $Executable = Join-Path $env:RUNNER_TEMP 'torus_trooper.exe' - python scripts/package_windows.py torus-trooper-0.23.0-windows-x86_64.zip $Executable + python scripts/package_windows.py torus-trooper-${{ steps.compat.outputs.project_version }}-windows-x86_64.zip $Executable if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $Extracted = Join-Path $env:RUNNER_TEMP 'torus-trooper-package' - Expand-Archive torus-trooper-0.23.0-windows-x86_64.zip $Extracted + Expand-Archive torus-trooper-${{ steps.compat.outputs.project_version }}-windows-x86_64.zip $Extracted $Package = Join-Path $Extracted 'torus-trooper' $Probe = Start-Process -FilePath (Join-Path $Package 'torus_trooper.exe') ` -ArgumentList @('--probe', '--no-sound', '--volume', '0') ` @@ -352,7 +367,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: torus-trooper-windows-x86_64 - path: torus-trooper-0.23.0-windows-x86_64.zip + path: torus-trooper-${{ steps.compat.outputs.project_version }}-windows-x86_64.zip if-no-files-found: error package-consumer: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9654102..9c7a4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## Unreleased +## 0.23.1 + +- Launch Windows packages from shortcuts or unrelated working directories while + keeping explicit save/model file paths relative to the caller. +- Add isolated Windows/Linux package-consumer tests and compatibility manifests. +- Make Windows tool-cache preparation transactional and preserve existing caches + when a download, checkout or extraction fails. - Keep menu/replay fades and game-over delays consistent across display frame rates, including after a slow frame. diff --git a/docs/compatibility.md b/docs/compatibility.md index 18c812f..cc7ea89 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -43,3 +43,24 @@ probe because its shaders use working-directory-relative paths. The source fix selects assets beside the executable and preserves explicit user file paths. Rebuilding and publishing a later package is required to deliver this fix to release-download users; existing release assets are not replaced by these tests. + +## Diagnose and recover setup + +Run `python scripts/doctor.py --v PATH_TO_TESTED_V --modules MODULE_DIRECTORY` +for compiler, tools and installed-release checks. Add `--runtime` to enumerate +Vulkan devices, or `--json` to capture a support report. Diagnostics do not repair +caches or change environment variables. GPU enumeration does not establish video +decode or OpenCL interoperability support. + +Windows setup stages new Git checkouts and tool extraction beside the destination, +then commits them only after success. Retry an interrupted download/clone with +the same command. Existing unverified, modified or damaged cache directories are +preserved: choose a fresh `-CacheDirectory` and retain the old one for inspection. +A cached archive with a wrong checksum is identified by path; remove only that +archive after inspecting the failure, then retry. Regression fixtures cover these +failure and recovery paths without downloading real tools. + +CI caches vcpkg binary packages by the exact ports commit and compatibility +manifest. vcpkg still validates package ABIs and installs the requested triplets; +compiler upgrades cannot reuse an unchecked installed-directory snapshot. +See [releasing](releasing.md) for collecting and publishing exact tested archives. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..60101f3 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,40 @@ +# Release the exact tested archives + +Update `v.mod` and add the matching version section to `CHANGELOG.md`. CI takes +archive names from `v.mod`; compatibility pins remain in `compatibility.json`. +Review and merge the change, then wait for all main CI jobs, including both +package consumers, to pass. Do not rebuild the binaries after these checks. + +In a clean checkout of that exact main commit, with Python 3 and authenticated +GitHub CLI: + +```sh +python scripts/prepare_release.py RUN_ID --output /path/to/new-release-directory +``` + +The command checks the source commit and all required jobs, downloads the exact +tested archives, and generates `SHA256SUMS`, a source/compatibility manifest and +release notes. It refuses an existing output directory and does not publish. +Review the archive names, manifest, notes and CI evidence before proceeding. + +Create and push only the matching tag, then create a draft with the two archives, +`SHA256SUMS` and `release-manifest.json`: + +```sh +git tag v0.23.1 +git push origin v0.23.1 +gh release create v0.23.1 --repo antono2/torus_trooper --verify-tag --draft --title "Torus Trooper 0.23.1" --notes-file /path/to/new-release-directory/release-notes.txt /path/to/new-release-directory/torus-trooper-0.23.1-windows-x86_64.zip /path/to/new-release-directory/torus-trooper-0.23.1-linux-x86_64.tar.gz /path/to/new-release-directory/SHA256SUMS /path/to/new-release-directory/release-manifest.json +``` + +Before publication, run **Published desktop package consumers** manually with +the draft tag. Authenticated CI can download draft assets and checks their +checksums and runtime behavior on both OSes. Publish only after those jobs pass: + +```sh +gh workflow run published-packages.yml --repo antono2/torus_trooper -f tag=v0.23.1 +gh release edit v0.23.1 --repo antono2/torus_trooper --draft=false --latest +``` + +The publication event also runs the download consumers. Preserve the older +release and its assets. A failed draft consumer is a release blocker; investigate +its preserved logs rather than replacing the tested archives with an ad hoc build. diff --git a/scripts/compatibility.py b/scripts/compatibility.py index 32b59eb..ef16332 100644 --- a/scripts/compatibility.py +++ b/scripts/compatibility.py @@ -24,6 +24,10 @@ def resolve(root, platform, variant='docking', compiler='stable'): if pins['modules'].get(name) != tag: raise ValueError(f'v.mod and compatibility.json disagree for {name}: {tag}') outputs = {key: value for key, value in pins.items() if isinstance(value, str)} + version = re.search(r"version:\s*'([0-9]+\.[0-9]+\.[0-9]+)'", (root / 'v.mod').read_text()) + if not version: + raise ValueError('v.mod must declare a release version') + outputs['project_version'] = version[1] for name, tag in pins['modules'].items(): if not re.fullmatch(r'[a-z0-9_]+', name) or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-standard)?', tag): raise ValueError(f'Invalid module pin: {name}={tag}') diff --git a/scripts/doctor.py b/scripts/doctor.py new file mode 100644 index 0000000..55ac920 --- /dev/null +++ b/scripts/doctor.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Read-only build/runtime diagnostics; use --json for a support report.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + + +def probe(command, timeout=15): + try: + result = subprocess.run(command, capture_output=True, text=True, errors='replace', timeout=timeout) + return result.returncode == 0, (result.stdout + result.stderr).strip() + except (OSError, subprocess.TimeoutExpired) as error: + return False, str(error) + + +def diagnose(root, compiler=None, modules=None, sdk=None, runtime=False): + checks = [] + def check(name, passed, detail, action, optional=False): + checks.append({'name': name, 'status': 'pass' if passed else ('warn' if optional else 'fail'), + 'detail': detail, 'action': '' if passed else action}) + try: + pins = json.loads((root / 'compatibility.json').read_text()) + if pins.get('schema_version') != 1: + raise ValueError('Unsupported schema_version') + except (OSError, ValueError) as error: + check('Compatibility manifest', False, str(error), 'Restore compatibility.json from this project revision.') + return {'checks': checks} + declared = dict(re.findall(r"'antono2\.([a-z0-9_]+)@([^']+)'", (root / 'v.mod').read_text())) + disagreement = [name for name, tag in declared.items() if pins.get('modules', {}).get(name) != tag] + check('Declared dependencies', not disagreement, ', '.join(disagreement) or 'Manifest agrees with v.mod', + 'Update compatibility.json and v.mod together; use the tested dependency versions.') + for tool in pins.get('build_tools', ['git', 'cmake']): + location = shutil.which(tool) + check(tool, bool(location), location or 'Not found on PATH', f'Install {tool}, reopen your shell and rerun diagnostics.') + compiler = str(compiler) if compiler else (os.environ.get('VEXE') or shutil.which('v')) + if compiler: + passed, detail = probe([compiler, '-version']) + expected = pins.get('v', '')[:7] or pins.get('v_release', '') + matches = passed and (not expected or expected in detail) + check('V compiler', matches, detail, 'Select the tested V compiler from compatibility.json or rerun the setup helper.') + else: + check('V compiler', False, 'No V compiler found', 'Run the project setup helper, or pass --v with the tested compiler path.') + modules = modules or os.environ.get('VMODULES') + if modules: + for name, tag in pins.get('modules', {}).items(): + checkout = Path(modules) / 'antono2' / name + head_ok, head = probe(['git', '-C', str(checkout), 'rev-parse', '--verify', 'HEAD']) + tag_ok, wanted = probe(['git', '-C', str(checkout), 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}']) + check('Module ' + name, head_ok and tag_ok and head == wanted, f'{checkout}: expected {tag}', + 'Preserve local changes and use a fresh module/cache directory with the declared releases.') + else: + check('Installed modules', False, 'VMODULES is not set', 'Pass --modules with the module directory used by setup.', optional=True) + sdk = sdk or os.environ.get('VULKAN_SDK') + needs_vulkan = 'ci_sdk' in pins or 'vulkan' in pins.get('modules', {}) + if needs_vulkan and os.name == 'nt': + available = bool(sdk) and (Path(sdk) / 'Lib/vulkan-1.lib').is_file() + check('Vulkan SDK', available, str(sdk or 'Not configured'), 'Install the Vulkan SDK or pass --sdk with its directory.') + if runtime and needs_vulkan: + executable = shutil.which('vulkaninfo') + if not executable and sdk: + candidate = Path(sdk) / ('Bin/vulkaninfo.exe' if os.name == 'nt' else 'bin/vulkaninfo') + if candidate.is_file(): + executable = str(candidate) + passed, detail = probe([executable, '--summary'], timeout=30) if executable else (False, 'vulkaninfo is unavailable') + check('Vulkan devices', passed, detail, 'Install Vulkan runtime tools and a compatible graphics driver; rerun --runtime.', optional=True) + return {'project': root.name, 'platform': os.name, 'compatibility': pins, 'checks': checks, + 'hardware_note': 'Device enumeration does not validate hardware video decoding or OpenCL interoperability.'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--v', type=Path, help='Tested V compiler executable') + parser.add_argument('--modules', type=Path) + parser.add_argument('--sdk', type=Path) + parser.add_argument('--runtime', action='store_true', help='Also enumerate Vulkan devices, when tools are available') + parser.add_argument('--json', action='store_true', help='Emit JSON without modifying the environment or caches') + args = parser.parse_args() + report = diagnose(Path(__file__).resolve().parents[1], args.v, args.modules, args.sdk, args.runtime) + if args.json: + print(json.dumps(report, indent=2)) + else: + for item in report['checks']: + print(f"{item['status'].upper()}: {item['name']}: {item['detail']}") + if item['action']: + print(' Next: ' + item['action']) + raise SystemExit(1 if any(item['status'] == 'fail' for item in report['checks']) else 0) + + +if __name__ == '__main__': + main() diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000..03f9f99 --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Collect exact CI-tested desktop archives and prepare a reviewed draft release.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + + +REPOSITORY = 'antono2/torus_trooper' +REQUIRED_JOBS = { + 'Fresh Windows setup from ordinary PowerShell', + 'Windows V simulation and runtime tests', 'V simulation and runtime tests', + 'Package consumer (windows)', 'Package consumer (linux)', + 'ubuntu-latest C bridge', 'windows-latest C bridge', 'macos-latest C bridge', +} + + +def command(arguments): + return subprocess.check_output(arguments, text=True).strip() + + +def validate_run(run, commit): + if run['headSha'] != commit or run['headBranch'] != 'main': + raise ValueError('Use successful main CI for the exact checked-out release commit') + if run['status'] != 'completed' or run['conclusion'] != 'success': + raise ValueError('Release CI must complete successfully') + jobs = {job['name']: job for job in run['jobs']} + if not REQUIRED_JOBS.issubset(jobs): + raise ValueError('Release CI is missing required setup/runtime/package-consumer jobs') + if any(job['conclusion'] != 'success' for job in run['jobs']): + raise ValueError('Every release CI job must pass') + + +def checksum(path): + digest = hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('run_id', type=int, help='Successful main CI run with both package consumers') + parser.add_argument('--output', type=Path, required=True, help='New directory for release assets') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + commit = command(['git', '-C', str(root), 'rev-parse', 'HEAD']) + if command(['git', '-C', str(root), 'status', '--porcelain']): + parser.error('Use a clean checkout of the release commit') + version = re.search(r"version:\s*'([0-9]+\.[0-9]+\.[0-9]+)'", (root / 'v.mod').read_text()) + if not version: + parser.error('v.mod must declare a release version') + version = version[1] + run = json.loads(command(['gh', 'run', 'view', str(args.run_id), '--repo', REPOSITORY, + '--json', 'headSha,headBranch,status,conclusion,jobs,url'])) + validate_run(run, commit) + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + assets = {} + for platform, extension in [('windows', 'zip'), ('linux', 'tar.gz')]: + command(['gh', 'run', 'download', str(args.run_id), '--repo', REPOSITORY, + '--name', 'torus-trooper-' + platform + '-x86_64', '--dir', str(output)]) + filename = f'torus-trooper-{version}-{platform}-x86_64.{extension}' + archive = output / filename + if not archive.is_file(): + raise ValueError(f'CI archive name disagrees with v.mod: {filename}') + assets[filename] = checksum(archive) + (output / 'SHA256SUMS').write_text(''.join(f'{digest} {name}\n' for name, digest in assets.items())) + manifest = {'version': version, 'source_commit': commit, 'ci_run': run['url'], + 'compatibility': json.loads((root / 'compatibility.json').read_text()), 'sha256': assets} + (output / 'release-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + changelog = (root / 'CHANGELOG.md').read_text() + section = re.search(r'^## ' + re.escape(version) + r'\s*\n(.*?)(?=^## |\Z)', changelog, re.M | re.S) + if not section: + raise ValueError('Changelog must describe this release') + (output / 'release-notes.txt').write_text(section[1].strip() + '\n\n' + 'Download the archive for your OS and keep its files together. A Vulkan-capable graphics driver is required.\n' + 'SHA256SUMS verifies the downloads; release-manifest.json records the source and passing CI run.\n') + print(f'Prepared v{version} from {commit}: {output}') + print('Review assets and notes, then follow docs/releasing.md. This command does not publish.') + + +if __name__ == '__main__': + main() diff --git a/scripts/test_doctor.py b/scripts/test_doctor.py new file mode 100644 index 0000000..612bfa7 --- /dev/null +++ b/scripts/test_doctor.py @@ -0,0 +1,41 @@ +"""Diagnostics must identify mismatches without repairing or changing caches.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from doctor import diagnose + + +class DoctorChecks(unittest.TestCase): + def fixture(self, root): + (root / 'compatibility.json').write_text(json.dumps({'schema_version': 1, + 'v': 'a' * 40, 'modules': {'memory': 'v1.4.0'}, 'build_tools': ['git']})) + (root / 'v.mod').write_text("Module { dependencies: ['antono2.memory@v1.4.0'] }") + + def test_missing_tools_are_actionable_and_read_only(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.fixture(root) + before = {p.name: p.read_bytes() for p in root.iterdir()} + with patch('doctor.shutil.which', return_value=None), patch.dict('os.environ', {}, clear=True): + report = diagnose(root) + failed = [item for item in report['checks'] if item['status'] == 'fail'] + self.assertEqual({item['name'] for item in failed}, {'git', 'V compiler'}) + self.assertTrue(all(item['action'] for item in failed)) + self.assertEqual(before, {p.name: p.read_bytes() for p in root.iterdir()}) + + def test_wrong_compiler_and_dependency_are_reported(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.fixture(root) + (root / 'v.mod').write_text("Module { dependencies: ['antono2.memory@v99.0.0'] }") + with patch('doctor.shutil.which', return_value='git'), patch('doctor.probe', return_value=(True, 'V wrong-revision')): + report = diagnose(root, compiler='fixture-v') + failed = {item['name'] for item in report['checks'] if item['status'] == 'fail'} + self.assertIn('V compiler', failed) + self.assertIn('Declared dependencies', failed) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_prepare_release.py b/scripts/test_prepare_release.py new file mode 100644 index 0000000..c225644 --- /dev/null +++ b/scripts/test_prepare_release.py @@ -0,0 +1,37 @@ +"""Release safety checks reject untested or mismatched source commits.""" +import unittest +from prepare_release import REQUIRED_JOBS, validate_run + + +class ReleaseChecks(unittest.TestCase): + def fixture(self): + return {'headSha': 'tested', 'headBranch': 'main', 'status': 'completed', + 'conclusion': 'success', + 'jobs': [{'name': name, 'conclusion': 'success'} for name in REQUIRED_JOBS]} + + def test_exact_successful_release_run(self): + validate_run(self.fixture(), 'tested') + + def test_different_source_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'exact'): + validate_run(self.fixture(), 'different') + + def test_missing_consumer_is_rejected(self): + run = self.fixture() + run['jobs'] = [job for job in run['jobs'] if job['name'] != 'Package consumer (windows)'] + with self.assertRaisesRegex(ValueError, 'missing'): + validate_run(run, 'tested') + + def test_pending_or_failed_jobs_are_rejected(self): + run = self.fixture() + run['jobs'][0]['conclusion'] = 'cancelled' + with self.assertRaisesRegex(ValueError, 'Every'): + validate_run(run, 'tested') + run = self.fixture() + run['status'] = 'in_progress' + with self.assertRaisesRegex(ValueError, 'complete'): + validate_run(run, 'tested') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_windows_setup.ps1 b/scripts/test_windows_setup.ps1 index b874299..d8c4c5f 100644 --- a/scripts/test_windows_setup.ps1 +++ b/scripts/test_windows_setup.ps1 @@ -26,6 +26,13 @@ try { Assert-TT ($Caught.StartsWith('Checkout is not at the tested revision')) 'Setup accepted the wrong revision.' Assert-TT ((& git -C $Checkout rev-parse HEAD) -eq $Revision) 'Wrong-revision check moved HEAD.' + $RetryCheckout = Join-Path $TestRoot 'retry checkout' + $Caught = '' + try { Get-WindowsSetupCheckout (Join-Path $TestRoot 'missing origin') $Revision $RetryCheckout } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught -like '*Retry setup*') 'Failed clone did not explain recovery.' + Assert-TT (-not (Test-Path -LiteralPath $RetryCheckout)) 'Failed clone committed an incomplete cache.' + Get-WindowsSetupCheckout $Origin $Revision $RetryCheckout + $ArchiveFile = Join-Path $TestRoot 'fixture.zip' $Payload = Join-Path $TestRoot 'tool.exe' 'fixture executable' | Set-Content $Payload @@ -39,6 +46,59 @@ try { Get-WindowsSetupArchive 'https://example.invalid/not-downloaded' $Hash $ArchiveFile $Extracted 'tool.exe' Get-WindowsSetupArchive 'https://example.invalid/not-downloaded' $Hash $ArchiveFile $Extracted 'tool.exe' Assert-TT (Test-Path -LiteralPath (Join-Path $Extracted 'tool.exe')) 'Verified archive was not prepared.' + + $Unverified = Join-Path $TestRoot 'unverified extraction' + New-Item -ItemType Directory $Unverified | Out-Null + 'keep me' | Set-Content (Join-Path $Unverified 'personal.txt') + $Caught = '' + try { Get-WindowsSetupArchive 'unused' $Hash $ArchiveFile $Unverified 'tool.exe' } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught.StartsWith('Unverified tool directory')) 'Setup accepted an unverified directory.' + Assert-TT ((Get-Content (Join-Path $Unverified 'personal.txt') -Raw).Trim() -eq 'keep me') 'Setup overwrote user files.' + + $MissingTool = Join-Path $TestRoot 'missing executable' + $Caught = '' + try { Get-WindowsSetupArchive 'unused' $Hash $ArchiveFile $MissingTool 'missing.exe' } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught.StartsWith('Downloaded tool is missing')) 'Missing executable was accepted.' + Assert-TT (-not (Test-Path -LiteralPath $MissingTool)) 'Failed extraction committed an incomplete cache.' + Get-WindowsSetupArchive 'unused' $Hash $ArchiveFile $MissingTool 'tool.exe' + + Remove-Item -LiteralPath (Join-Path $Extracted 'tool.exe') + $Caught = '' + try { Get-WindowsSetupArchive 'unused' $Hash $ArchiveFile $Extracted 'tool.exe' } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught.StartsWith('Incomplete or mismatched tool cache')) 'Damaged verified cache was reused.' + Assert-TT (Test-Path -LiteralPath (Join-Path $MissingTool 'tool.exe')) 'Damaged-cache recovery changed a working cache.' + + $RetryArchive = Join-Path $TestRoot 'retry.zip' + $RetryTool = Join-Path $TestRoot 'retry tool' + function curl.exe { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]]$DownloadArguments) + $OutputIndex = [Array]::IndexOf($DownloadArguments, '--output') + [IO.File]::WriteAllText($DownloadArguments[$OutputIndex + 1], 'interrupted download') + $global:LASTEXITCODE = 7 + } + try { + $Caught = '' + try { Get-WindowsSetupArchive 'fixture' $Hash $RetryArchive $RetryTool 'tool.exe' } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught -like '*failed with exit code 7*') 'Download failure was ignored.' + Assert-TT (-not (Test-Path -LiteralPath $RetryArchive)) 'Partial download became a cached archive.' + Assert-TT (-not (Test-Path -LiteralPath $RetryTool)) 'Partial download created an installed tool.' + function curl.exe { + param([Parameter(ValueFromRemainingArguments = $true)] [string[]]$DownloadArguments) + $OutputIndex = [Array]::IndexOf($DownloadArguments, '--output') + Copy-Item -LiteralPath $ArchiveFile -Destination $DownloadArguments[$OutputIndex + 1] -Force + $global:LASTEXITCODE = 0 + } + Get-WindowsSetupArchive 'fixture' $Hash $RetryArchive $RetryTool 'tool.exe' + Assert-TT (Test-Path -LiteralPath (Join-Path $RetryTool 'tool.exe')) 'Retry did not recover an interrupted download.' + } finally { Remove-Item -LiteralPath Function:curl.exe } + + $SavedPath = $env:PATH + try { + $env:PATH = '' + $Caught = '' + try { & (Join-Path $PSScriptRoot 'setup_windows.ps1') -Check } catch { $Caught = $_.Exception.Message } + Assert-TT ($Caught -like 'Install git*rerun setup*') 'Missing Git did not explain recovery.' + } finally { $env:PATH = $SavedPath } Write-Host 'Windows setup cache and checksum tests passed.' } finally { $ResolvedTestRoot = [IO.Path]::GetFullPath($TestRoot) diff --git a/scripts/windows_setup_tools.ps1 b/scripts/windows_setup_tools.ps1 index 0f36e0f..26d1917 100644 --- a/scripts/windows_setup_tools.ps1 +++ b/scripts/windows_setup_tools.ps1 @@ -6,9 +6,18 @@ function Invoke-WindowsSetupCommand([string]$Command, [string[]]$Arguments) { function Get-WindowsSetupCheckout([string]$Url, [string]$Revision, [string]$Directory) { if (-not (Test-Path -LiteralPath $Directory)) { - Invoke-WindowsSetupCommand git @('-c', 'core.longpaths=true', 'clone', '--filter=blob:none', '--no-checkout', $Url, $Directory) - Invoke-WindowsSetupCommand git @('-C', $Directory, 'fetch', '--depth', '1', 'origin', $Revision) - Invoke-WindowsSetupCommand git @('-C', $Directory, 'checkout', '--detach', $Revision) + $Staging = "$Directory.setup-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Directory))) | Out-Null + try { + Invoke-WindowsSetupCommand git @('-c', 'core.longpaths=true', 'clone', '--filter=blob:none', '--no-checkout', $Url, $Staging) + Invoke-WindowsSetupCommand git @('-C', $Staging, 'fetch', '--depth', '1', 'origin', $Revision) + Invoke-WindowsSetupCommand git @('-C', $Staging, 'checkout', '--detach', $Revision) + [IO.Directory]::Move([IO.Path]::GetFullPath($Staging), [IO.Path]::GetFullPath($Directory)) + } catch { + throw "Could not prepare ${Directory}. Retry setup; existing caches are preserved. $($_.Exception.Message)" + } finally { + Remove-WindowsSetupStagingDirectory $Staging $Directory + } } if (-not (Test-Path -LiteralPath (Join-Path $Directory '.git'))) { throw "Existing directory is not a managed Git checkout: $Directory. Choose another -CacheDirectory." @@ -25,6 +34,16 @@ function Get-WindowsSetupCheckout([string]$Url, [string]$Revision, [string]$Dire } } +function Remove-WindowsSetupStagingDirectory([string]$Staging, [string]$Destination) { + $Resolved = [IO.Path]::GetFullPath($Staging) + $Target = [IO.Path]::GetFullPath($Destination) + if ([IO.Path]::GetDirectoryName($Resolved) -ne [IO.Path]::GetDirectoryName($Target) -or + -not ([IO.Path]::GetFileName($Resolved) -match ('^' + [regex]::Escape([IO.Path]::GetFileName($Target)) + '\.setup-[0-9a-f]{32}$'))) { + throw "Refusing staging cleanup outside the tool cache: $Resolved" + } + if (Test-Path -LiteralPath $Resolved) { Remove-Item -LiteralPath $Resolved -Recurse -Force } +} + function Expand-WindowsSetupVerifiedArchive([string]$Archive, [string]$Hash, [string]$Directory) { if ((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash -ne $Hash) { throw "Download checksum mismatch: $Archive. Remove this cached archive and retry." @@ -42,6 +61,9 @@ function Get-WindowsSetupArchive([string]$Url, [string]$Hash, [string]$Archive, } return } + if (Test-Path -LiteralPath $Directory) { + throw "Unverified tool directory: $Directory. Preserve its contents and choose another -CacheDirectory." + } if (-not (Test-Path -LiteralPath $Archive)) { Write-Host "Downloading $Url" $Download = "$Archive.download.zip" @@ -55,9 +77,15 @@ function Get-WindowsSetupArchive([string]$Url, [string]$Hash, [string]$Archive, } Move-Item -LiteralPath $Download -Destination $Archive -Force } - Expand-WindowsSetupVerifiedArchive $Archive $Hash $Directory - if (-not (Test-Path -LiteralPath (Join-Path $Directory $Executable))) { - throw "Downloaded tool is missing ${Executable}: $Directory" + $Staging = "$Directory.setup-$([Guid]::NewGuid().ToString('N'))" + try { + Expand-WindowsSetupVerifiedArchive $Archive $Hash $Staging + if (-not (Test-Path -LiteralPath (Join-Path $Staging $Executable))) { + throw "Downloaded tool is missing ${Executable}: $Archive. Check the archive/pin before retrying." + } + $Hash | Set-Content -LiteralPath (Join-Path $Staging '.archive-sha256') -Encoding ascii + [IO.Directory]::Move([IO.Path]::GetFullPath($Staging), [IO.Path]::GetFullPath($Directory)) + } finally { + Remove-WindowsSetupStagingDirectory $Staging $Directory } - $Hash | Set-Content -LiteralPath $Marker -Encoding ascii } diff --git a/v.mod b/v.mod index cfa3f4a..11c4bb2 100644 --- a/v.mod +++ b/v.mod @@ -2,7 +2,7 @@ Module { name: 'torus_trooper' author: 'Anton Oreskin' description: 'Deterministic 3D arcade shooter with Vulkan graphics for desktop' - version: '0.23.0' + version: '0.23.1' license: 'MIT' repo_url: 'https://github.com/antono2/torus_trooper' dependencies: [ From 145409010b7b0a064bd472cde168a2439dd875cf Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 11 Oct 2026 11:14:03 +0200 Subject: [PATCH 2/2] Check diagnostics before setup and run shader tests after tool installation --- .github/workflows/ci.yml | 4 +++- scripts/doctor.py | 13 ++++++++----- scripts/test_doctor.py | 15 +++++++++++++++ scripts/windows_setup_tools.ps1 | 12 ++++++++---- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0782d7b..2643276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,9 @@ jobs: id: compat shell: bash run: | - python -m unittest discover -s scripts -p 'test_*.py' + python scripts/test_distribution.py + python scripts/test_doctor.py + python scripts/test_prepare_release.py python scripts/compatibility.py --github-output "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/scripts/doctor.py b/scripts/doctor.py index 55ac920..cd4c666 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -17,7 +17,7 @@ def probe(command, timeout=15): return False, str(error) -def diagnose(root, compiler=None, modules=None, sdk=None, runtime=False): +def diagnose(root, compiler=None, modules=None, sdk=None, runtime=False, profile='stable'): checks = [] def check(name, passed, detail, action, optional=False): checks.append({'name': name, 'status': 'pass' if passed else ('warn' if optional else 'fail'), @@ -39,8 +39,10 @@ def check(name, passed, detail, action, optional=False): compiler = str(compiler) if compiler else (os.environ.get('VEXE') or shutil.which('v')) if compiler: passed, detail = probe([compiler, '-version']) - expected = pins.get('v', '')[:7] or pins.get('v_release', '') - matches = passed and (not expected or expected in detail) + selected = pins.get('v_release', '') if profile == 'release' else ( + pins.get('v3_v', pins.get('v', '')) if profile == 'v3' else pins.get('v', '')) + expected = selected[:7] if selected else (pins.get('v_release', '') if profile == 'stable' else '') + matches = passed and bool(expected) and expected in detail check('V compiler', matches, detail, 'Select the tested V compiler from compatibility.json or rerun the setup helper.') else: check('V compiler', False, 'No V compiler found', 'Run the project setup helper, or pass --v with the tested compiler path.') @@ -67,7 +69,7 @@ def check(name, passed, detail, action, optional=False): executable = str(candidate) passed, detail = probe([executable, '--summary'], timeout=30) if executable else (False, 'vulkaninfo is unavailable') check('Vulkan devices', passed, detail, 'Install Vulkan runtime tools and a compatible graphics driver; rerun --runtime.', optional=True) - return {'project': root.name, 'platform': os.name, 'compatibility': pins, 'checks': checks, + return {'project': root.name, 'platform': os.name, 'profile': profile, 'compatibility': pins, 'checks': checks, 'hardware_note': 'Device enumeration does not validate hardware video decoding or OpenCL interoperability.'} @@ -77,9 +79,10 @@ def main(): parser.add_argument('--modules', type=Path) parser.add_argument('--sdk', type=Path) parser.add_argument('--runtime', action='store_true', help='Also enumerate Vulkan devices, when tools are available') + parser.add_argument('--profile', choices=['stable', 'v3', 'release'], default='stable', help='Compiler profile to diagnose') parser.add_argument('--json', action='store_true', help='Emit JSON without modifying the environment or caches') args = parser.parse_args() - report = diagnose(Path(__file__).resolve().parents[1], args.v, args.modules, args.sdk, args.runtime) + report = diagnose(Path(__file__).resolve().parents[1], args.v, args.modules, args.sdk, args.runtime, args.profile) if args.json: print(json.dumps(report, indent=2)) else: diff --git a/scripts/test_doctor.py b/scripts/test_doctor.py index 612bfa7..ba63017 100644 --- a/scripts/test_doctor.py +++ b/scripts/test_doctor.py @@ -36,6 +36,21 @@ def test_wrong_compiler_and_dependency_are_reported(self): self.assertIn('V compiler', failed) self.assertIn('Declared dependencies', failed) + def test_v3_compiler_is_checked_against_its_own_profile(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.fixture(root) + pins = json.loads((root / 'compatibility.json').read_text()) + pins['v3_v'] = 'b' * 40 + (root / 'compatibility.json').write_text(json.dumps(pins)) + with patch('doctor.shutil.which', return_value='git'), patch('doctor.probe', return_value=(True, 'V bbbbbbb')): + release = diagnose(root, compiler='fixture-v') + v3 = diagnose(root, compiler='fixture-v', profile='v3') + unsupported = diagnose(root, compiler='fixture-v', profile='release') + self.assertEqual(next(item for item in release['checks'] if item['name'] == 'V compiler')['status'], 'fail') + self.assertEqual(next(item for item in v3['checks'] if item['name'] == 'V compiler')['status'], 'pass') + self.assertEqual(next(item for item in unsupported['checks'] if item['name'] == 'V compiler')['status'], 'fail') + if __name__ == '__main__': unittest.main() diff --git a/scripts/windows_setup_tools.ps1 b/scripts/windows_setup_tools.ps1 index 26d1917..2853f9b 100644 --- a/scripts/windows_setup_tools.ps1 +++ b/scripts/windows_setup_tools.ps1 @@ -67,10 +67,14 @@ function Get-WindowsSetupArchive([string]$Url, [string]$Hash, [string]$Archive, if (-not (Test-Path -LiteralPath $Archive)) { Write-Host "Downloading $Url" $Download = "$Archive.download.zip" - if (Get-Command curl.exe -ErrorAction SilentlyContinue) { - Invoke-WindowsSetupCommand curl.exe @('--fail', '--location', '--retry', '3', '--connect-timeout', '30', '--max-time', '900', '--silent', '--show-error', '--output', $Download, $Url) - } else { - Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Download -TimeoutSec 120 + try { + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + Invoke-WindowsSetupCommand curl.exe @('--fail', '--location', '--retry', '3', '--connect-timeout', '30', '--max-time', '900', '--silent', '--show-error', '--output', $Download, $Url) + } else { + Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Download -TimeoutSec 120 + } + } catch { + throw "Download failed for ${Url}. Retry setup; existing installed tools are preserved. $($_.Exception.Message)" } if ((Get-FileHash -LiteralPath $Download -Algorithm SHA256).Hash -ne $Hash) { throw "Download checksum mismatch: $Url. Retry setup to download again."