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:
Ethanw05andCursor committed 2026-09-12 00:48:04 -06:00
1 parent ec09165491
commit 3dd61acc62
4 files changed
+216 -12

No files matched your search

+7 -2
View File
@@ -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:
+98
View File
@@ -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)
+51
View File
@@ -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
View File
@@ -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()