diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e43b9b4..89ecbdcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ jobs: python-version: '3.13' - name: Install setup packager run: python -m pip install pyinstaller==6.19.0 numpy==2.2.6 Pillow==11.3.0 + - name: Check updater without starting game + run: python -m unittest discover -s tools -p test_updater.py -v - name: Build Windows package shell: pwsh env: diff --git a/Build-Release.ps1 b/Build-Release.ps1 index dc047897..ea23d37a 100644 --- a/Build-Release.ps1 +++ b/Build-Release.ps1 @@ -78,6 +78,7 @@ try { Copy-Item -LiteralPath docs/installation.md -Destination "$stage/docs/installation.md" Copy-Item -LiteralPath docs/retail-renderer.md -Destination "$stage/docs/retail-renderer.md" Copy-Item -LiteralPath docs/crash-reports.md -Destination "$stage/docs/crash-reports.md" + Copy-Item -LiteralPath docs/updates.md -Destination "$stage/docs/updates.md" New-Item -ItemType Directory -Path "$stage/licenses" -Force | Out-Null Copy-Item -LiteralPath tools/vendor/utt/LICENSE -Destination "$stage/licenses/UTT.txt" Copy-Item -LiteralPath tools/vendor/university/LICENSE-PROJECT.md -Destination "$stage/licenses/CustomEngineLayer.txt" diff --git a/crates/skate-game/build.rs b/crates/skate-game/build.rs index 5fba44eb..1eb090fc 100644 --- a/crates/skate-game/build.rs +++ b/crates/skate-game/build.rs @@ -17,6 +17,9 @@ fn main() { } let git = |args: &[&str]| Command::new("git").args(args).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()); let revision = git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "revision-unavailable".into()); + println!("cargo:rerun-if-env-changed=GITHUB_RUN_NUMBER"); + println!("cargo:rustc-env=SKATE_RELEASE_REVISION={revision}"); + println!("cargo:rustc-env=SKATE_RELEASE_BUILD={}", env::var("GITHUB_RUN_NUMBER").unwrap_or_else(|_| "0".into())); let dirty = git(&["status", "--porcelain", "--untracked-files=normal"]).map(|s| !s.is_empty()); let compiler = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("--version").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default(); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); diff --git a/crates/skate-game/src/updater.rs b/crates/skate-game/src/updater.rs index 2bd2aebc..2966a431 100644 --- a/crates/skate-game/src/updater.rs +++ b/crates/skate-game/src/updater.rs @@ -23,6 +23,7 @@ fn helper_command(recover: bool, automatic: bool) -> Result<(Command, PathBuf, P let signal = temp.join("ready"); let request = temp.join("request.json"); let data = serde_json::json!({ + "revision": env!("SKATE_RELEASE_REVISION"), "build": env!("SKATE_RELEASE_BUILD"), "root": root, "signal": signal, "automatic": automatic, "recover": recover, "cwd": std::env::current_dir().map_err(|e| e.to_string())?, "args": std::env::args().skip(1).collect::>() diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 00000000..0fb29314 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,104 @@ +# Player updates + +Pause → **Updates** opens a native desktop update window. Select **Stable** +(default) or **Latest** (includes experimental prereleases), then **Check now**. +Release notes appear as plain, scrollable text. **Update** authorizes downloading, +verification, a clean game exit, replacement and restart. **Cancel** closes the +offer, or cancels a download before shutdown. No installation occurs on a check. +Save any gameplay you want to retain before choosing Update. + +Startup checks run in a separate process, at most once per six hours per channel. +Offline/rate-limit/check failures are silent at startup and visible in manual +checks. Manual checks bypass the six-hour interval. Switching channels clears the +old offer and performs a fresh check; channel changes are disabled during work. +Switching to Stable never downgrades an already installed experimental build; +it waits for a newer stable build. Cancelled offers are checked again on a later +eligible startup, or through the menu. + +The helper replaces only `skate3rust.exe`, `support/skate3setup.exe`, +`support/skate3update.exe`, and `release.json`. Assets, maps, settings, mods, +character libraries and setup's installation marker are never replaced. The same +working directory and arguments are used for restart; existing asset selection +continues to work. Packaged releases are required; developer binaries do not +silently adopt a downloaded build. + +# Package protocol and release maintenance + +`release.json` accompanies the ZIP and is also inside it. Schema 1 identifies the +repository, `windows-x64` target, tag, full source revision, monotonically increasing +release-workflow run number (`build`), and SHA-256 of all three program files. +The executable embeds its revision and build number; its local metadata and hash +must match before checking. Local builds use build 0 and are not update sources. + +Build numbers, not lexical tags, timestamps, GitHub's chosen latest release, or +the workspace's currently fixed Cargo version, determine upgrade ordering. Never +reset/replace the release workflow's run-number sequence without a protocol +migration. Rerunning a build preserves its identity and does not offer an upgrade +to an installation of that build. Publish a new release/run to ship another build. +No downgrades are offered across channels. Equal build IDs are not upgrades. + +The updater scans pages of 100 published releases, rejects drafts, filters the +channel, and requires the ZIP, checksum and compatible metadata. It selects the +highest build, using release ID only as a deterministic tie breaker. A scan that +exceeds its page/time bound fails rather than claiming the player is up to date. +Older releases without this metadata are skipped. Existing installations predating +the updater need one manual portable-package upgrade to bootstrap it. + +At implementation time the configured repository was private with no releases. +Normal players need publicly accessible releases. For private testing, launch with +`SKATE_UPDATE_GITHUB_TOKEN` set to a token with read access to repository contents. +The updater does not store this credential; it uses authenticated API asset URLs +and strips authorization on redirects. Never place tokens in launch arguments, +tracked scripts or release packages. No repository visibility or release was +changed by this implementation. + +The ZIP checksum is mandatory; GitHub's asset digest is also checked when present. +The internal/external manifests and individual executable hashes must agree. +This trusts the project's GitHub release publisher and HTTPS, not a separate +signing key. Archive paths, duplicate names, links and oversized expansion are +rejected. Only fixed program filenames are written; notes and manifest paths are +never executed. No shell is used. The helper runs from a temporary copy so its +installed binary can also be replaced. + +# Failure recovery + +Downloads stage under `.update-transaction/new` before requesting a successful +Bevy exit, which the crash supervisor treats as normal. Windows replacement is +retried for up to 45 seconds per file; other running game instances can prevent +replacement. An OS lock serializes updater instances in the same installation. +All four previous files are backed up before the durable journal is published. +Replacement failures restore those backups and restart the old program when +rollback succeeds. Do not close the installer during replacement. + +If interrupted, the next normal launch detects the journal and exits to a copied +helper, which restores the old program before restarting. Close other game +instances if recovery cannot replace their locked binaries. Backups remain in +`.update-transaction/old`; keep this directory until recovery succeeds. If the +game executable itself is missing or externally damaged, restore those four backup +files manually or unpack the portable release over the program files. User data +does not need extraction again. Recovery cannot guarantee detection of semantic +bugs in a successfully installed new game; backups are retained for manual return. + +# Validation + +Automated, isolated checks: `python -m unittest discover -s tools -p test_updater.py -v`. +The release workflow runs these before packaging. Rust integration can be checked +with `cargo check -p skate-game --bin skate3rust --no-default-features`. + +Player manual checklist (use a disposable copy of a packaged installation): + +- Start an older packaged build: a newer eligible release shows notes and Update/Cancel; + playing remains possible while checking. Cancel leaves the executable unchanged. +- Open Pause → Updates, check manually, and switch Stable/Latest. Verify prerelease + filtering, persisted selection after restart, and no stale offer after switching. +- Choose Update, then cancel during download: game stays open and program files + remain unchanged. Check again and finish: progress, clean exit, one restart, + correct version, no crash dialog, no ISO/setup prompt, preserved player data. +- Disconnect networking: startup stays playable without a dialog; manual check + explains the failure. Repeat with a simulated rate-limit response. +- In an isolated fixture, corrupt the ZIP/checksum: failure must precede shutdown. + Hold a target file locked or deny a replacement: verify rollback and retained + backups. Interrupt after the journal is written, then launch again: verify recovery. + +GitHub references: [release listing, pagination and latest-release semantics](https://docs.github.com/en/rest/releases/releases), +[asset digests](https://docs.github.com/en/rest/releases/assets). diff --git a/tools/test_updater.py b/tools/test_updater.py index 56fc05ba..247e7de7 100644 --- a/tools/test_updater.py +++ b/tools/test_updater.py @@ -13,7 +13,7 @@ import updater as u def metadata(build=2): return dict(schema=1, repository=u.REPO, target='windows-x64', build=build, - tag=f'v{build}', revision='a' * 40) + tag=f'v{build}', revision='a' * 40, files={n: 'b'*64 for n in u.FILES[:-1]}) class UpdaterTests(unittest.TestCase): @@ -102,6 +102,34 @@ class UpdaterTests(unittest.TestCase): self.assertEqual((root/'player-data').read_bytes(), b'untouched') self.assertFalse((tx/'journal.json').exists()) + def test_success_and_interrupted_recovery(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); tx = root/'.update-transaction' + for name in u.FILES: + (root/name).parent.mkdir(parents=True, exist_ok=True) + (root/name).write_bytes(b'old') + (tx/'new'/name).parent.mkdir(parents=True, exist_ok=True) + (tx/'new'/name).write_bytes(b'new') + u.install(root, tx) + self.assertTrue(all((root/n).read_bytes() == b'new' for n in u.FILES)) + # Simulate interrupted replacement with durable backups/journal. + u.atomic(tx/'journal.json', {'protocol': 1}) + u.rollback(root, tx) + self.assertTrue(all((root/n).read_bytes() == b'old' for n in u.FILES)) + self.assertFalse((tx/'journal.json').exists()) + + def test_offline_and_rate_limit(self): + with patch.object(u, 'fetch', side_effect=OSError('offline')), self.assertRaises(OSError): + u.discover(metadata(1), 'Stable', threading.Event()) + error = u.urllib.error.HTTPError(u.API, 429, 'rate limit', {}, None) + with patch.object(u.urllib.request.OpenerDirector, 'open', side_effect=error), self.assertRaisesRegex(ValueError, 'rate limit'): + u.fetch(u.API, threading.Event(), 100) + + def test_redirect_never_forwards_private_token(self): + req = u.urllib.request.Request(u.API + '/assets/1', headers={'Authorization': 'Bearer test-only'}) + redirected = u.DownloadRedirect().redirect_request(req, None, 302, 'Found', {}, 'https://release-assets.githubusercontent.com/file') + self.assertIsNone(redirected.get_header('Authorization')) + if __name__ == '__main__': unittest.main() diff --git a/tools/updater.py b/tools/updater.py index e4e4532b..233a9b5a 100644 --- a/tools/updater.py +++ b/tools/updater.py @@ -8,10 +8,10 @@ import queue import re import shutil import subprocess -import tempfile import threading import time import urllib.request +import urllib.error import zipfile REPO = 'SK8-ENGINE/skate-3-rust-engine' @@ -21,10 +21,28 @@ FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe' PREFIX = 'skate3rust-windows-x64/' +class DownloadRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not newurl.startswith('https://'): + raise ValueError('Insecure redirect') + redirected = super().redirect_request(req, fp, code, msg, headers, newurl) + # Private asset requests redirect to signed CDN URLs. Never forward a PAT. + if redirected is not None: + redirected.remove_header('Authorization') + return redirected + + +def asset_url(asset): + return asset['url'] if os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') else asset['browser_download_url'] + + def atomic(path, value): path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_suffix('.tmp') - temp.write_text(json.dumps(value), encoding='utf-8') + with temp.open('w', encoding='utf-8') as output: + json.dump(value, output) + output.flush() + os.fsync(output.fileno()) os.replace(temp, path) @@ -36,23 +54,61 @@ def read_json(path, default=None): def identity(m): + if not isinstance(m, dict): + raise ValueError('Invalid release metadata') if (m.get('schema') != 1 or m.get('target') != 'windows-x64' or m.get('repository') != REPO or not isinstance(m.get('build'), int) or m['build'] <= 0 or not isinstance(m.get('tag'), str) - or not re.fullmatch(r'[0-9a-f]{40}', m.get('revision', ''))): + or not isinstance(m.get('revision'), str) + or not re.fullmatch(r'[0-9a-f]{40}', m['revision'])): raise ValueError('Incompatible release metadata') return m['build'] +def program_metadata(m): + identity(m) + files = m.get('files') + if not isinstance(files, dict) or any(not isinstance(files.get(name), str) + or not re.fullmatch('[0-9a-f]{64}', files[name]) for name in FILES[:-1]): + raise ValueError('Missing program hashes') + + +def safe_local_paths(root): + # Never follow a user junction/symlink into asset stores or another install. + paths = [root / name for name in FILES] + tx = root / '.update-transaction' + paths += [tx / side / name for side in ('old', 'new') for name in FILES] + for path in paths: + while path != root: + if path.is_symlink() or path.is_junction(): + raise ValueError('Updater program directories cannot contain links or junctions') + path = path.parent + + def fetch(url, cancel, limit, progress=lambda value: None): + if cancel.is_set(): + raise InterruptedError('Cancelled') # URLs originate only from the fixed repository API, never notes/manifest. if not (url.startswith(API) or url.startswith(f'https://github.com/{REPO}/releases/download/')): raise ValueError('Unexpected download URL') - req = urllib.request.Request(url, headers={'User-Agent': 'Skate3RustEngine-Updater/1', - 'X-GitHub-Api-Version': '2022-11-28'}) + headers = {'User-Agent': 'Skate3RustEngine-Updater/1', 'X-GitHub-Api-Version': '2022-11-28'} + if url.startswith(API): + headers['Accept'] = 'application/octet-stream' if '/assets/' in url else 'application/vnd.github+json' + token = os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') + if token: + headers['Authorization'] = 'Bearer ' + token + req = urllib.request.Request(url, headers=headers) deadline = time.monotonic() + 300 result = bytearray() - with urllib.request.urlopen(req, timeout=15) as response: + try: + response = urllib.request.build_opener(DownloadRedirect()).open(req, timeout=15) + except urllib.error.HTTPError as error: + if error.code in (403, 429): + raise ValueError('GitHub rate limit or access restriction; try again later') from error + if error.code == 404: + raise ValueError('Releases are not publicly available, or private-release access is missing') from error + raise + with response: if not response.url.startswith('https://'): raise ValueError('Insecure redirect') while True: @@ -75,6 +131,7 @@ def eligible(release, channel): def discover(current, channel, cancel): + current_build = identity(current) candidates = [] deadline = time.monotonic() + 120 # Exhaust pagination; never silently claim current after a truncated scan. @@ -83,15 +140,18 @@ def discover(current, channel, cancel): raise TimeoutError('Release check timed out') releases = json.loads(fetch(f'{API}?per_page=100&page={page}', cancel, 8 * 1024 * 1024)) for release in releases: + if time.monotonic() > deadline: + raise TimeoutError('Release check timed out') if not eligible(release, channel): continue assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'} if not {PACKAGE, PACKAGE + '.sha256', 'release.json'} <= assets.keys(): continue try: - meta = json.loads(fetch(assets['release.json']['browser_download_url'], cancel, 65536)) + meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536)) build = identity(meta) - if meta['tag'] != release['tag_name'] or build <= identity(current): + program_metadata(meta) + if meta['tag'] != release['tag_name'] or build <= current_build: continue except (ValueError, KeyError): continue @@ -105,10 +165,10 @@ def discover(current, channel, cancel): def stage(candidate, directory, cancel, progress): _, _, release, assets, meta = candidate - checksum = fetch(assets[PACKAGE + '.sha256']['browser_download_url'], cancel, 1024).decode('ascii').split() + checksum = fetch(asset_url(assets[PACKAGE + '.sha256']), cancel, 1024).decode('ascii').split() if len(checksum) != 2 or checksum[1] != PACKAGE or not re.fullmatch('[0-9a-fA-F]{64}', checksum[0]): raise ValueError('Invalid release checksum') - archive = fetch(assets[PACKAGE]['browser_download_url'], cancel, 1024 * 1024 * 1024, progress) + archive = fetch(asset_url(assets[PACKAGE]), cancel, 1024 * 1024 * 1024, progress) digest = hashlib.sha256(archive).hexdigest() if digest != checksum[0].lower(): raise ValueError('Package checksum mismatch') @@ -156,6 +216,7 @@ def retry(operation): def rollback(root, tx): + safe_local_paths(root) journal = read_json(tx / 'journal.json') if journal is None: return @@ -168,6 +229,7 @@ def rollback(root, tx): def install(root, tx): + safe_local_paths(root) # Back up everything before the first replacement. Journal survives power loss. for name in FILES: backup = tx / 'old' / name @@ -183,12 +245,14 @@ def install(root, tx): raise -def main(): +def main(request=None): parser = argparse.ArgumentParser() parser.add_argument('--request', type=Path, required=True) args = parser.parse_args() - request = read_json(args.request) + request = request or read_json(args.request) + args.request.unlink(missing_ok=True) root = Path(request['root']).resolve() + safe_local_paths(root) tx = root / '.update-transaction' # OS releases the lock after crashes; concurrent game instances cannot install. lock = (root / '.update.lock').open('a+b') @@ -257,6 +321,9 @@ def main(): nonlocal candidate, generation generation += 1 candidate = None + notes.configure(state='normal') + notes.delete('1.0', 'end') + notes.configure(state='disabled') cancel.clear() settings['channel'] = channel.get() settings[channel.get()] = time.time() @@ -266,7 +333,16 @@ def main(): pass status.set('Checking GitHub…') selected = channel.get() - work('checked', lambda: discover(read_json(root / 'release.json', {}), selected, cancel)) + def perform_check(): + current = read_json(root / 'release.json', {}) + program_metadata(current) + with (root / 'skate3rust.exe').open('rb') as executable: + digest = hashlib.file_digest(executable, 'sha256').hexdigest() + if (current['revision'] != request['revision'] or str(current['build']) != request['build'] + or digest != current['files']['skate3rust.exe']): + raise ValueError('This executable does not match its release metadata') + return discover(current, selected, cancel) + work('checked', perform_check) def do_update(): nonlocal automatic @@ -376,4 +452,16 @@ def main(): if __name__ == '__main__': - main() + # Suppress PyInstaller's generic exception/crash dialog for routine updater + # failures, including unwritable portable directories before UI creation. + import sys + request = read_json(Path(sys.argv[2]), {}) if len(sys.argv) == 3 and sys.argv[1] == '--request' else {} + try: + main(request) + except Exception as error: + if request and not request.get('automatic', True): + try: + import tkinter.messagebox + tkinter.messagebox.showerror('Updates unavailable', str(error)[:300] + '\nYou can keep playing. Try again later.') + except Exception: + pass