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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
shell: bash
run: |
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:
Expand Down Expand Up @@ -189,17 +191,17 @@ 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 '
docker run --rm --uts=host --ipc=host \
-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
Expand All @@ -213,7 +215,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:
Expand Down Expand Up @@ -314,7 +316,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'
Expand All @@ -336,10 +353,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') `
Expand All @@ -352,7 +369,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:
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
40 changes: 40 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions scripts/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down
97 changes: 97 additions & 0 deletions scripts/doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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, 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'),
'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'])
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.')
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, 'profile': profile, '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('--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, args.profile)
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()
87 changes: 87 additions & 0 deletions scripts/prepare_release.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading