Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-07 01:05:43 +01:00
parent 161100cf08
commit e421dcfc15
14 changed files with 640 additions and 50 deletions
+84 -3
View File
@@ -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
+104
View File
@@ -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