Record Workshop versions in Drone changelogs and retire local sending

This commit is contained in:
2026-10-17 17:42:39 +02:00
parent 8f2e1f210d
commit 38fd8f7dd2
10 changed files with 222 additions and 96 deletions
+1
View File
@@ -8,6 +8,7 @@ from prefab_identity import check_prefabs
ROOT=Path(__file__).resolve().parents[1]
BASE='58D0FB3206B6F859'
def check():
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_workshop_changelog.py'],check=True)
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
+3 -60
View File
@@ -1,68 +1,11 @@
#!/usr/bin/env python3
"""Post a prepared changelog after verifying a successful push to origin/main.
The webhook is local configuration, never a command-line argument or tracked file.
API: https://docs.discord.com/developers/resources/webhook#execute-webhook
"""
import argparse
import json
import shlex
import subprocess
"""Retired local sender: Discord changelogs are delivered exclusively by Drone."""
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--commit', required=True, help='Commit just pushed to origin/main')
parser.add_argument('--message-file', required=True, type=Path, help='Prepared player-facing changelog, at most 2000 characters')
args = parser.parse_args()
commit = subprocess.check_output(['git', 'rev-parse', '--verify', args.commit + '^{commit}'], cwd=ROOT, text=True).strip()
ssh = 'ssh -F ' + shlex.quote(str(Path.home() / '.ssh/config'))
remote = subprocess.check_output(['git', '-c', 'core.sshCommand=' + ssh, 'ls-remote', '--exit-code', 'origin', 'refs/heads/main'], cwd=ROOT, text=True).split()[0]
if commit != remote:
parser.error('Requested commit is not the current origin/main tip; no message sent.')
message = args.message_file.read_text().strip()
if not message or len(message) > 2000:
parser.error('Changelog must contain 1..2000 characters.')
state_path = ROOT / '.local/discord-changelog-state.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if state.get('commit') == commit:
print('This commit is already recorded; no duplicate sent. Status: ' + state.get('status', 'unknown'))
return
url = (ROOT / '.local/discord-webhook.url').read_text().strip()
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or parsed.netloc != 'discord.com' or not parsed.path.startswith('/api/webhooks/') or parsed.query or parsed.fragment:
parser.error('Local configuration must contain a Discord HTTPS webhook URL.')
payload = json.dumps({'content': message, 'allowed_mentions': {'parse': []}}).encode()
request = urllib.request.Request(url + '?wait=true', data=payload, headers={
'Content-Type': 'application/json', 'User-Agent': 'EscapistsReintroduced-Changelog/1.0'})
# Preserve an uncertain attempt so a timeout cannot silently cause duplicates.
state_path.write_text(json.dumps({'commit': commit, 'status': 'pending'}) + '\n')
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
result = json.load(response)
if not result.get('id'):
raise ValueError('Missing message confirmation')
except urllib.error.HTTPError as error:
print('Discord returned HTTP ' + str(error.code) + '; inspect delivery before retrying.', file=sys.stderr)
return 1
except (urllib.error.URLError, TimeoutError, ValueError):
print('Discord delivery could not be confirmed; inspect delivery before retrying.', file=sys.stderr)
return 1
state_path.write_text(json.dumps({'commit': commit, 'status': 'sent', 'message_id': result['id']}) + '\n')
print('Discord changelog delivered for ' + commit[:7])
return 0
print('Local Discord sending is disabled. Use the Drone changelog pipeline.', file=sys.stderr)
return 1
if __name__ == '__main__':
+9 -1
View File
@@ -45,7 +45,15 @@ def latest_changes(before, after):
latest = new[0]
if old and (latest == old[0] or latest[0] in [title for title, _ in old[1:]]):
return None # Unchanged newest entry, or removal/reordering of old entries.
message = '**' + latest[0] + '**\n\n' + latest[1]
version_note = ''
preamble = after.split('\n## ', 1)[0]
versions = re.findall(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*\r?$',
preamble, re.MULTILINE)
if 'Latest published Workshop version:' in preamble:
if len(versions) != 1 or any(int(part) > 32000 for part in versions[0].split('.')):
raise ChangelogError('Invalid Workshop version record in CHANGELOG.md.')
version_note = 'Last published Workshop version: **' + versions[0] + '**\n\n'
message = '**' + latest[0] + '**\n\n' + version_note + latest[1]
if len(message.encode('utf-16-le')) // 2 > 2000:
raise ChangelogError('Newest changelog entry exceeds Discord’s 2000-character limit.')
return message
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Read Workbench's bundled version; record it after a confirmed Workshop upload."""
import argparse
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION_LINE = 'Latest published Workshop version:'
DEFAULT_MANIFEST = (Path.home() / '.local/share/Steam/steamapps/compatdata/1874910/pfx'
/ 'drive_c/users/steamuser/AppData/Local/Temp/Arma Reforger Workbench'
/ 'Publishing/2528466DE220D91C/manifest.json')
def valid_version(value):
return (isinstance(value, str) and re.fullmatch(r'\d+\.\d+\.\d+', value) is not None
and all(int(part) <= 32000 for part in value.split('.')))
def bundled_version(manifest, project):
guid = re.search(r'\bGUID\s+"([A-Fa-f0-9]{16})"', project)
asset = manifest.get('asset', {}) if isinstance(manifest, dict) else {}
if (not guid or not isinstance(asset, dict) or not isinstance(asset.get('id'), str)
or asset['id'].upper() != guid[1].upper()):
raise ValueError('Publishing manifest does not belong to this addon.')
version = asset.get('version') # Top-level "version" is the manifest schema, not the mod version.
if not valid_version(version):
raise ValueError('Publishing manifest has no valid addon version.')
return version
def update_changelog(text, version):
if not valid_version(version):
raise ValueError('Invalid Workshop version.')
if not text.startswith('# Changelog\n'):
raise ValueError('Expected # Changelog at the start of CHANGELOG.md.')
line = VERSION_LINE + ' **' + version + '**'
existing = list(re.finditer(r'^Latest published Workshop version:.*$', text, re.MULTILINE))
if len(existing) > 1:
raise ValueError('Changelog contains multiple Workshop version records.')
if existing:
match = existing[0]
if match.start() > text.find('\n## ') >= 0:
raise ValueError('Workshop version record must be above the changelog entries.')
return text[:match.start()] + line + text[match.end():]
return text.replace('# Changelog\n', '# Changelog\n\n' + line + '\n', 1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, default=DEFAULT_MANIFEST)
parser.add_argument('--published', action='store_true',
help='Confirm the bundle was successfully uploaded; update CHANGELOG.md')
args = parser.parse_args()
try:
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:
path = ROOT / 'CHANGELOG.md'
before = path.read_text()
after = update_changelog(before, version)
if before != after:
path.write_text(after)
print('Recorded published Workshop version ' + version + ' in CHANGELOG.md.')
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:
print('Cannot read/record Workshop version: ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())