Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read Workbench's bundled version; record it after a confirmed Workshop upload."""
|
||||
"""Synchronize the published Workshop version, or inspect a local Workbench bundle."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION_LINE = 'Latest published Workshop version:'
|
||||
@@ -30,6 +32,71 @@ def bundled_version(manifest, project):
|
||||
return version
|
||||
|
||||
|
||||
class WorkshopPage(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.active = False
|
||||
self.payloads = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'script' and dict(attrs).get('id') == '__NEXT_DATA__':
|
||||
self.active = True
|
||||
self.payloads.append('')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'script':
|
||||
self.active = False
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.active:
|
||||
self.payloads[-1] += data
|
||||
|
||||
|
||||
def online_version(page, guid):
|
||||
parser = WorkshopPage()
|
||||
parser.feed(page)
|
||||
if len(parser.payloads) != 1:
|
||||
raise ValueError('Workshop page has no unique version metadata.')
|
||||
data = json.loads(parser.payloads[0])
|
||||
asset = data['props']['pageProps']['asset']
|
||||
if asset.get('id') != guid or asset.get('private') is True or asset.get('blocked') is True:
|
||||
raise ValueError('Workshop page does not identify the public addon.')
|
||||
version = asset.get('currentVersionNumber')
|
||||
if not valid_version(version) or not any(v.get('version') == version and v.get('assetId') == guid
|
||||
and v.get('published') is True and v.get('approved') is True
|
||||
for v in asset.get('versions', [])):
|
||||
raise ValueError('Workshop current version is not confirmed as published and approved.')
|
||||
return version
|
||||
|
||||
|
||||
def prevent_downgrade(text, version):
|
||||
existing = re.search(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*$', text, re.MULTILINE)
|
||||
if existing and tuple(map(int, version.split('.'))) < tuple(map(int, existing[1].split('.'))):
|
||||
raise ValueError('Workshop response is older than the recorded publication; refusing stale metadata.')
|
||||
|
||||
|
||||
def sync_version(project, text, native_compat=None):
|
||||
match = re.search(r'\bGUID\s+"([A-Fa-f0-9]{16})"', project)
|
||||
if not match:
|
||||
raise ValueError('Addon GUID is missing.')
|
||||
guid = match[1].upper()
|
||||
if native_compat:
|
||||
from workshop_query import native_version
|
||||
version = native_version(ROOT, native_compat, guid)
|
||||
else:
|
||||
request = urllib.request.Request('https://reforger.armaplatform.com/workshop/' + guid,
|
||||
headers={'Cache-Control': 'no-cache'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
page = response.read(2 * 1024 * 1024 + 1)
|
||||
if len(page) > 2 * 1024 * 1024:
|
||||
raise ValueError('Workshop response exceeds the expected size.')
|
||||
version = online_version(page.decode('utf-8'), guid)
|
||||
if not valid_version(version):
|
||||
raise ValueError('Workshop returned an invalid version.')
|
||||
prevent_downgrade(text, version)
|
||||
return version
|
||||
|
||||
|
||||
def update_changelog(text, version):
|
||||
if not valid_version(version):
|
||||
raise ValueError('Invalid Workshop version.')
|
||||
@@ -50,10 +117,24 @@ def update_changelog(text, version):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', type=Path, default=DEFAULT_MANIFEST)
|
||||
parser.add_argument('--published', action='store_true',
|
||||
action = parser.add_mutually_exclusive_group()
|
||||
action.add_argument('--sync', action='store_true', help='Fetch the live published version and update CHANGELOG.md')
|
||||
parser.add_argument('--native-compat', type=Path, help='Use the live game catalogue with this disposable prefix instead of the website')
|
||||
action.add_argument('--published', action='store_true',
|
||||
help='Confirm the bundle was successfully uploaded; update CHANGELOG.md')
|
||||
args = parser.parse_args()
|
||||
if args.native_compat and not args.sync:
|
||||
parser.error('--native-compat requires --sync')
|
||||
try:
|
||||
if args.sync:
|
||||
path = ROOT / 'CHANGELOG.md'
|
||||
before = path.read_text()
|
||||
version = sync_version((ROOT / 'addons/core/addon.gproj').read_text(), before, args.native_compat)
|
||||
after = update_changelog(before, version)
|
||||
if before != after:
|
||||
path.write_text(after)
|
||||
print('Verified published Workshop version ' + version + '; CHANGELOG.md synchronized.')
|
||||
return 0
|
||||
manifest = json.loads(args.manifest.read_text(encoding='utf-8-sig'))
|
||||
version = bundled_version(manifest, (ROOT / 'addons/core/addon.gproj').read_text())
|
||||
if args.published:
|
||||
@@ -66,7 +147,7 @@ def main():
|
||||
else:
|
||||
print('Workbench bundle version: ' + version)
|
||||
print('Bundling alone does not confirm publication. Use --published after a successful upload.')
|
||||
except (OSError, ValueError, AttributeError) as error:
|
||||
except (OSError, ValueError, AttributeError, KeyError, TypeError) as error:
|
||||
print('Cannot read/record Workshop version: ' + str(error), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Query the public Workshop catalogue using a disposable, silent game session."""
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from headless_game import assert_prefix_idle, cleanup_owned_run
|
||||
|
||||
SCRIPT = '''class ERR_VersionQuery : PageParams
|
||||
{
|
||||
override void OnPack() { StoreString("search", "Co10 escape Reintroduced"); }
|
||||
}
|
||||
modded class SCR_AddonManager
|
||||
{
|
||||
protected ref ERR_VersionQuery m_Query;
|
||||
protected ref BackendCallback m_Callback;
|
||||
override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);GetGame().GetCallqueue().CallLater(QueryVersion,10000,false);
|
||||
}
|
||||
void QueryVersion()
|
||||
{
|
||||
m_Query=new ERR_VersionQuery();m_Query.limit=100;m_Query.offset=0;
|
||||
m_Callback=new BackendCallback();m_Callback.SetOnSuccess(VersionReady);
|
||||
GetGame().GetBackendApi().GetWorkshop().RequestPage(m_Callback,m_Query,true);
|
||||
}
|
||||
void VersionReady(BackendCallback callback)
|
||||
{
|
||||
array<WorkshopItem> items={};GetGame().GetBackendApi().GetWorkshop().GetPageItems(items);
|
||||
foreach(WorkshopItem item:items)
|
||||
{
|
||||
if(item.Id()!="2528466DE220D91C")continue;
|
||||
Revision revision=item.GetLatestRevision();
|
||||
if(revision)PrintFormat("ERR_WORKSHOP_PUBLISHED %1 %2",item.Id(),revision.GetVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def published_from_log(text, guid):
|
||||
versions = set(re.findall(r'\bERR_WORKSHOP_PUBLISHED ' + re.escape(guid) +
|
||||
r' (\d+\.\d+\.\d+)\s*$', text, re.MULTILINE))
|
||||
if len(versions) > 1:
|
||||
raise ValueError('Workshop returned conflicting published versions.')
|
||||
return next(iter(versions), None)
|
||||
|
||||
|
||||
def native_version(root, compat, guid):
|
||||
if guid != '2528466DE220D91C':
|
||||
raise ValueError('Native query is restricted to the Escape addon.')
|
||||
compat = Path(compat).resolve()
|
||||
steam = Path.home() / '.local/share/Steam'
|
||||
if not compat.is_relative_to(root / '.local') or not (compat / 'pfx').is_dir():
|
||||
raise ValueError('Use an existing disposable compat prefix under this checkout/.local.')
|
||||
proton = steam / 'steamapps/common/Proton - Experimental/proton'
|
||||
game = steam / 'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe'
|
||||
if not proton.is_file() or not game.is_file():
|
||||
raise ValueError('Installed Reforger and Proton Experimental are required for the native query.')
|
||||
with (compat / '.erri-workbench.lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
assert_prefix_idle(compat)
|
||||
scratch = root / '.local/workshop-version'
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='query-', dir=scratch) as temp:
|
||||
work = Path(temp)
|
||||
addon = work / 'addons/query'
|
||||
(addon / 'Scripts/Game').mkdir(parents=True)
|
||||
(addon / 'addon.gproj').write_text('GameProject { ID "ERR_VersionQuery" GUID "8F729DB72D635EF1" Dependencies { "58D0FB3206B6F859" } }\n')
|
||||
(addon / 'Scripts/Game/VersionQuery.c').write_text(SCRIPT)
|
||||
profile = work / 'profile'
|
||||
profile.mkdir()
|
||||
env = os.environ.copy()
|
||||
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam),
|
||||
WINEPREFIX=str(compat / 'pfx'), SteamAppId='1874880', SteamGameId='1874880')
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-r', '30', '--',
|
||||
str(proton), 'run', str(game), '-profile', 'Z:' + str(profile),
|
||||
'-addonDownloadDir', 'Z:' + str(profile), '-addonsDir', 'Z:' + str(work / 'addons'),
|
||||
'-addons', '8F729DB72D635EF1', '-noSound', '-noSplash', '-noThrow',
|
||||
'-disableCrashReporter', '-logconsole']
|
||||
version = None
|
||||
with (work / 'launch.log').open('w') as output:
|
||||
child = subprocess.Popen(command, cwd=game.parent, env=env, stdout=output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 60
|
||||
while child.poll() is None and time.monotonic() < deadline:
|
||||
for log in (profile / 'logs').glob('*/console.log'):
|
||||
version = published_from_log(log.read_text(errors='replace'), guid)
|
||||
if version:
|
||||
break
|
||||
if version:
|
||||
break
|
||||
time.sleep(.5)
|
||||
finally:
|
||||
cleanup = cleanup_owned_run(child, proton, env, compat)
|
||||
if not cleanup.get('cleanup_confirmed'):
|
||||
raise ValueError('Workshop query process cleanup could not be confirmed.')
|
||||
if not version:
|
||||
raise ValueError('Live Workshop query did not return a published version within 60 seconds.')
|
||||
return version
|
||||
Reference in New Issue
Block a user