mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Fix branch updater 401 by publishing public branch releases.
CI artifacts require GitHub auth, so skyline-driving-update now publishes downloadable release assets and the updater prefers those over Actions artifacts. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ec09165491
commit
3dd61acc62
4 files changed
+216
-12
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
+60
-10
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user