diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75d36638..fa156a89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: target/release.json publish: needs: windows - if: github.event_name == 'release' || github.ref == 'refs/heads/main' + if: github.event_name == 'release' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/skyline-driving-update' runs-on: ubuntu-latest permissions: contents: write @@ -74,10 +74,15 @@ jobs: name: skate3rust-windows-x64 path: target - name: Publish rolling experimental - if: github.event_name != 'release' + if: github.event_name != 'release' && github.ref == 'refs/heads/main' env: GH_TOKEN: ${{ github.token }} run: python tools/publish_experimental.py --directory target + - name: Publish branch release + if: github.event_name != 'release' && github.ref == 'refs/heads/skyline-driving-update' + env: + GH_TOKEN: ${{ github.token }} + run: python tools/publish_branch.py --directory target --branch skyline-driving-update - name: Attach numbered release if: github.event_name == 'release' env: diff --git a/tools/publish_branch.py b/tools/publish_branch.py new file mode 100644 index 00000000..b8244cc1 --- /dev/null +++ b/tools/publish_branch.py @@ -0,0 +1,98 @@ +"""Publish one rolling prerelease for a feature branch.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import zipfile + +from updater import REPO, PACKAGE, PREFIX, identity, program_metadata + +BRANCH_RE = re.compile(r'[A-Za-z0-9._/-]{1,120}') + + +def gh(*args, payload=None): + command = ['gh', *args] + if payload is not None: + command += ['--input', '-'] + result = subprocess.run(command, input=json.dumps(payload) if payload is not None else None, + text=True, capture_output=True) + if result.returncode: + raise RuntimeError(result.stderr.strip() or 'GitHub command failed') + return result.stdout + + +def api(path, method='GET', payload=None): + return json.loads(gh('api', f'repos/{REPO}/{path}', '--method', method, payload=payload) or 'null') + + +def validate(directory): + meta = json.loads((directory / 'release.json').read_text(encoding='utf-8-sig')) + identity(meta) + program_metadata(meta) + archive = directory / PACKAGE + with archive.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + checksum = (directory / (PACKAGE + '.sha256')).read_text().split() + if checksum != [digest, PACKAGE]: + raise ValueError('Package checksum mismatch') + with zipfile.ZipFile(archive) as z: + if json.loads(z.read(PREFIX + 'release.json').decode('utf-8-sig')) != meta: + raise ValueError('Package identity mismatch') + for name, expected in meta['files'].items(): + if hashlib.sha256(z.read(PREFIX + name)).hexdigest() != expected: + raise ValueError('Program checksum mismatch') + return meta, digest + + +def publish(directory, branch): + branch = (branch or '').strip() + if not BRANCH_RE.fullmatch(branch): + raise ValueError('Invalid branch name') + meta, digest = validate(directory) + releases = json.loads(gh('api', f'repos/{REPO}/releases?per_page=100', '--paginate', '--slurp')) + release = next((r for page in releases for r in page if r['tag_name'] == branch), None) + if release and (release.get('immutable') or not release.get('prerelease')): + raise ValueError(f'Release tag {branch} must be a mutable prerelease') + if release is None: + release = api('releases', 'POST', dict( + tag_name=branch, + target_commitish=meta['revision'], + name=branch, + draft=True, + prerelease=True, + make_latest='false', + )) + (directory / (PACKAGE + '.sha256')).write_text(digest + ' ' + PACKAGE + '\n', encoding='ascii') + for name in (PACKAGE, PACKAGE + '.sha256', 'release.json'): + gh('release', 'upload', branch, str(directory / name), '--repo', REPO, '--clobber') + current = api(f"releases/{release['id']}") + assets = {a['name'] for a in current.get('assets', []) if a.get('state') == 'uploaded'} + if not {PACKAGE, PACKAGE + '.sha256', 'release.json'} <= assets: + raise ValueError('Uploaded branch release is incomplete') + changes = gh('api', f'repos/{REPO}/commits?sha={meta["revision"]}&per_page=10') + commits = json.loads(changes) + notes = (f"Rolling build **{meta['build']}**, commit `{meta['revision'][:12]}`.\n\n" + f"[Download Windows ZIP](https://github.com/{REPO}/releases/download/{branch}/{PACKAGE})\n\n" + f"Choose **Branch** in the in-game Updates window and enter `{branch}`.\n\n" + "### Recent commits\n\n" + '\n'.join( + f"- {c['sha'][:8]} {c['commit']['message'].splitlines()[0]}" for c in commits)) + api(f"releases/{release['id']}", 'PATCH', dict( + name=branch, + body=notes, + target_commitish=meta['revision'], + draft=False, + prerelease=True, + make_latest='false', + )) + api('git/refs/tags/' + branch, 'PATCH', dict(sha=meta['revision'], force=True)) + print(f'Published branch release {branch} build {meta["build"]}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--directory', type=Path, required=True) + parser.add_argument('--branch', required=True) + publish(parser.parse_args().directory, parser.parse_args().branch) diff --git a/tools/test_updater.py b/tools/test_updater.py index 9d696217..432e8742 100644 --- a/tools/test_updater.py +++ b/tools/test_updater.py @@ -2,6 +2,7 @@ import hashlib import io import json +import os from pathlib import Path import tempfile import threading @@ -149,6 +150,54 @@ class UpdaterTests(unittest.TestCase): redirected = u.DownloadRedirect().redirect_request(req, None, 302, 'Found', {}, 'https://release-assets.githubusercontent.com/file') self.assertIsNone(redirected.get_header('Authorization')) + def test_github_api_token_on_actions_requests(self): + with patch.dict(os.environ, {'SKATE_UPDATE_GITHUB_TOKEN': 'test-token'}, clear=False): + headers = u.github_request_headers(u.BRANCHES_API) + self.assertEqual(headers.get('Authorization'), 'Bearer test-token') + headers = u.github_request_headers(f'https://github.com/{u.REPO}/releases/download/v1/{u.PACKAGE}') + self.assertNotIn('Authorization', headers) + + def test_branch_release_discovery_and_stage(self): + meta = {**metadata(11), 'tag': 'experimental', 'revision': 'c' * 40} + meta['files'] = {name: hashlib.sha256(b'new').hexdigest() for name in u.FILES[:-1]} + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as z: + for name in [*meta['files'], 'release.json']: + z.writestr(u.PREFIX + name, json.dumps(meta) if name == 'release.json' else b'new') + package_bytes = archive.getvalue() + digest = hashlib.sha256(package_bytes).hexdigest() + release = dict( + id=88, + tag_name='skyline-driving-update', + draft=False, + prerelease=True, + assets=[ + dict(name=u.PACKAGE, state='uploaded', browser_download_url='package'), + dict(name=u.PACKAGE + '.sha256', state='uploaded', browser_download_url='checksum'), + dict(name='release.json', state='uploaded', browser_download_url='manifest'), + ], + ) + + def fetch(url, *args): + if url.endswith('/tags/skyline-driving-update'): + return json.dumps(release).encode() + if url == 'manifest': + return json.dumps(meta).encode() + if url == 'checksum': + return (digest + ' ' + u.PACKAGE).encode() + if url == 'package': + return package_bytes + raise AssertionError(url) + + current = metadata(10) + with patch.object(u, 'fetch', fetch): + candidate = u.discover_branch_release(current, 'skyline-driving-update', threading.Event()) + self.assertIsNotNone(candidate) + self.assertEqual(candidate[0], 11) + with tempfile.TemporaryDirectory() as temp: + u.stage(candidate, Path(temp), threading.Event(), lambda _: None) + self.assertEqual((Path(temp) / 'new/skate3rust.exe').read_bytes(), b'new') + def test_branch_discovery_and_stage(self): meta = {**metadata(9), 'tag': 'experimental', 'revision': 'b' * 40} meta['files'] = {name: hashlib.sha256(b'new').hexdigest() for name in u.FILES[:-1]} @@ -167,6 +216,8 @@ class UpdaterTests(unittest.TestCase): artifact_bytes = artifact.getvalue() def fetch(url, *args): + if '/releases/tags/' in url: + raise u.urllib.error.HTTPError(url, 404, 'not found', {}, None) if url.startswith(u.BRANCHES_API): return json.dumps([{'name': 'main'}, {'name': 'skyline'}]).encode() if '/workflows/' in url and '/runs?' in url: diff --git a/tools/updater.py b/tools/updater.py index e60a1494..24de3cec 100644 --- a/tools/updater.py +++ b/tools/updater.py @@ -100,24 +100,35 @@ def allowed_fetch_url(url): return False +def github_request_headers(url): + 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' + elif url.startswith('https://api.github.com/'): + headers['Accept'] = 'application/vnd.github+json' + token = os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') + if token and url.startswith('https://api.github.com/'): + headers['Authorization'] = 'Bearer ' + token + return headers + + 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 allowed_fetch_url(url): raise ValueError('Unexpected download URL') - 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) + req = urllib.request.Request(url, headers=github_request_headers(url)) deadline = time.monotonic() + 300 result = bytearray() try: response = urllib.request.build_opener(DownloadRedirect()).open(req, timeout=15) except urllib.error.HTTPError as error: + if error.code == 401: + raise ValueError( + 'GitHub sign-in is required for CI artifact downloads. ' + 'Use a published branch release, or set SKATE_UPDATE_GITHUB_TOKEN.' + ) from error if error.code in (403, 429): raise ValueError('GitHub rate limit or access restriction; try again later') from error if error.code == 404: @@ -204,10 +215,42 @@ def package_from_artifact(artifact_zip, package): return payload, checksum +def discover_branch_release(current, branch, cancel, repair=False): + from urllib.parse import quote + branch = (branch or '').strip() + try: + release = json.loads(fetch(f'{API}/tags/{quote(branch, safe="")}', cancel, 8 * 1024 * 1024)) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise + assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'} + required = {PACKAGE, PACKAGE + '.sha256', 'release.json'} + if not required <= assets.keys(): + return None + try: + meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536)) + build = identity(meta) + program_metadata(meta) + except (ValueError, KeyError): + return None + if meta['revision'] == current.get('revision') and not repair: + return None + notes = dict( + tag_name=branch, + body=(f'Branch `{branch}`\n' + f'Build {build} · revision {meta.get("revision", "")[:12]}'), + ) + return (build, release['id'], notes, assets, meta) + + def discover_branch(current, branch, cancel, repair=False): branch = (branch or '').strip() if not branch or not re.fullmatch(r'[A-Za-z0-9._/-]{1,120}', branch): raise ValueError('Enter a valid GitHub branch name') + candidate = discover_branch_release(current, branch, cancel, repair=repair) + if candidate is not None: + return candidate current_revision = current.get('revision') for run in branch_workflow_runs(branch, cancel): if run.get('conclusion') != 'success': @@ -272,10 +315,17 @@ def discover(current, channel, cancel, repair=False): return max(candidates, key=lambda item: item[:2]) if candidates else None +def release_package_name(assets, meta): + package = package_name(meta) + if assets and package + '.sha256' not in assets and PACKAGE + '.sha256' in assets: + package = PACKAGE + return package + + def stage(candidate, directory, cancel, progress): _, _, release, assets, meta = candidate[:5] artifact_zip = candidate[5] if len(candidate) > 5 else None - package = package_name(meta) + package = release_package_name(assets, meta) if artifact_zip is not None: archive, checksum = package_from_artifact(artifact_zip, package) else: @@ -381,7 +431,7 @@ def main(request=None): settings_path = Path(os.environ.get('LOCALAPPDATA', str(Path.home()))) / 'Skate3RustEngine/settings/updates.json' settings = read_json(settings_path, {}) channel = tk.StringVar(value=settings.get('channel') if settings.get('channel') in ('Stable', 'Latest', 'Branch') else 'Stable') - branch = tk.StringVar(value=settings.get('branch') or 'main') + branch = tk.StringVar(value=settings.get('branch') or 'skyline-driving-update') status = tk.StringVar(value='Ready to check') ttk.Label(win, text='Updates — Update downloads, then closes and restarts the game.').pack(pady=8) choice = ttk.Combobox(win, textvariable=channel, values=('Stable', 'Latest', 'Branch'), state='readonly') @@ -393,7 +443,7 @@ def main(request=None): branch_row.pack(fill='x', padx=12, pady=(0, 4)) channel_help = ttk.Label( win, - text='Stable: published releases. Latest: rolling Experimental. Branch: latest CI build for a GitHub branch.', + text='Stable: published releases. Latest: rolling Experimental. Branch: published build for a GitHub branch (e.g. skyline-driving-update).', wraplength=620, ) channel_help.pack()