Record Workshop versions in Drone changelogs and retire local sending
This commit is contained in:
@@ -11,6 +11,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apk add --no-cache git
|
- apk add --no-cache git
|
||||||
- python -m unittest discover -s tests -p test_discord_changelog_ci.py
|
- python -m unittest discover -s tests -p test_discord_changelog_ci.py
|
||||||
|
- python -m unittest discover -s tests -p test_workshop_changelog.py
|
||||||
|
|
||||||
- name: notify-discord
|
- name: notify-discord
|
||||||
image: python:3.13-alpine
|
image: python:3.13-alpine
|
||||||
|
|||||||
@@ -1,37 +1,32 @@
|
|||||||
# Push changelogs
|
# Push changelogs
|
||||||
|
|
||||||
The user authorized Discord changelogs after new changes are successfully pushed.
|
Drone is the sole Discord changelog sender. Its first delivery was verified in
|
||||||
Maintain a concise, player-facing newest entry in `CHANGELOG.md` for each update.
|
build #1 on 2026-10-17, and the user confirmed receipt and disabled local sending.
|
||||||
The `.drone.yml` pipeline can deliver it automatically for pushes to `main` from
|
Do not run a local/manual Discord sender after pushes or as an automatic fallback.
|
||||||
any checkout. See `docs/discord-changelog-ci.md` for activation and secret setup.
|
`tools/discord_changelog.py` is disabled and cannot send messages.
|
||||||
|
|
||||||
On 2026-10-17 the user confirmed Drone is prepared with `discord_webhook_url`
|
For player-facing changes, maintain the newest entry in `CHANGELOG.md` before
|
||||||
and requested a test push. Drone now owns delivery; do not run the manual helper
|
pushing to `origin/main`. Keep it concise and describe only fixes/features, without
|
||||||
for that push or subsequent pushes unless the user explicitly requests a fallback.
|
server, deployment, Git push status, operational disclaimers or footers. Do not
|
||||||
|
backfill old changes unless requested. Internal workflow-only changes need no
|
||||||
|
announcement.
|
||||||
|
|
||||||
Once Drone activation and its `discord_webhook_url` secret are confirmed, let
|
After a confirmed successful Workbench Workshop upload, run
|
||||||
Drone deliver the changelog and do not also run the manual helper. Check its
|
`python3 tools/workshop_changelog.py --published` before committing the changelog.
|
||||||
delivery result when available; do not claim delivery from Git push success alone.
|
This reads the local publishing manifest and records the version in the changelog
|
||||||
|
preamble. Compilation/bundling alone is not confirmation of publication; check
|
||||||
|
the successful upload result first. With no flag the helper only displays the
|
||||||
|
bundled version. Drone includes the recorded version with new changelog entries;
|
||||||
|
a version-only edit does not announce an old entry again.
|
||||||
|
|
||||||
Until Drone activation is confirmed, after each successful push to `origin/main`
|
The `.drone.yml` pipeline posts the latest changed entry using Drone's
|
||||||
from this checkout:
|
`discord_webhook_url` repository secret. See `docs/discord-changelog-ci.md`.
|
||||||
|
Check the Drone result and notification log when available; a successful Git push
|
||||||
|
alone does not prove delivery. Report delivery failures honestly. Inspect Discord
|
||||||
|
before rerunning an uncertain delivery or rebuilding the same push, since builds
|
||||||
|
do not share delivery state and retries can produce duplicates.
|
||||||
|
|
||||||
1. Prepare a concise, player-facing changelog covering the commits in that push.
|
The saved local webhook in `.local/discord-webhook.url` is no longer used for
|
||||||
Include only the changelog: describe fixes/features accurately, without server,
|
post-push notifications. Never print it, commit it, or put it into changelogs or
|
||||||
deployment, or Git push status. Do not add operational disclaimers or footers.
|
command-line arguments. Do not configure an additional Discord push webhook
|
||||||
Phrase gameplay fixes without mentioning the server (for example, "Fixed radio
|
alongside Drone.
|
||||||
station generation after an empty-session wait").
|
|
||||||
2. Save the message to a temporary file and run
|
|
||||||
`python3 tools/discord_changelog.py --commit <pushed-sha> --message-file <path>`.
|
|
||||||
3. Report a delivery failure honestly. Do not blindly retry a pending delivery;
|
|
||||||
check Discord first to avoid duplicates.
|
|
||||||
|
|
||||||
The webhook credential is in `.local/discord-webhook.url` (ignored, owner-only).
|
|
||||||
Never print it, commit it, or put it into changelogs or command-line arguments.
|
|
||||||
The helper verifies the remote commit, disables mentions, and records delivery in
|
|
||||||
`.local/discord-changelog-state.json`. Do not backfill old commits unless requested.
|
|
||||||
|
|
||||||
The manual helper is a fallback assistant workflow, not a Git hook. The Drone
|
|
||||||
pipeline replaces it after activation; do not configure a second Discord push
|
|
||||||
webhook alongside it. Inspect Discord before retrying an uncertain CI delivery
|
|
||||||
or rebuilding the same push, since builds do not share delivery state.
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
# Discord changelogs through Drone
|
# Discord changelogs through Drone
|
||||||
|
|
||||||
|
Active since 2026-10-17: [build #1](https://ci.internet-master.cz/escapists/EscapistsReintroduced/1)
|
||||||
|
confirmed delivery, and the user confirmed receipt. Drone is the sole sender.
|
||||||
|
The retired local `tools/discord_changelog.py` exits without sending, including
|
||||||
|
when invoked with its old arguments.
|
||||||
|
|
||||||
`.drone.yml` runs a Docker pipeline for pushes to `main`. It tests the notifier,
|
`.drone.yml` runs a Docker pipeline for pushes to `main`. It tests the notifier,
|
||||||
then reads `CHANGELOG.md` from the pushed commit. Discord receives only the newest
|
then reads `CHANGELOG.md` from the pushed commit. Discord receives only the newest
|
||||||
entry's heading and body, with mentions disabled and no build, Git or hosting
|
entry's heading and body, plus the last recorded published Workshop version when
|
||||||
status added.
|
available, with mentions disabled and no build, Git or hosting status added.
|
||||||
|
|
||||||
## One-time setup
|
## One-time setup
|
||||||
|
|
||||||
@@ -15,8 +20,8 @@ status added.
|
|||||||
3. Push these files to `main`, then verify the `notify-discord` step and its
|
3. Push these files to `main`, then verify the `notify-discord` step and its
|
||||||
corresponding Discord message. Creating the YAML alone does not activate Drone
|
corresponding Discord message. Creating the YAML alone does not activate Drone
|
||||||
or install its Gitea webhook.
|
or install its Gitea webhook.
|
||||||
4. Once Drone delivery is enabled, stop the manual post-push notifier. Both
|
4. Keep local/manual notification sending disabled. Do not add a second sender
|
||||||
delivery paths running for the same push would produce duplicate messages.
|
or automatically fall back to local delivery after a failed Drone build.
|
||||||
|
|
||||||
Drone's repository activation manages the Gitea event webhook. This notification
|
Drone's repository activation manages the Gitea event webhook. This notification
|
||||||
pipeline does not compile, publish or update the mission.
|
pipeline does not compile, publish or update the mission.
|
||||||
@@ -39,6 +44,24 @@ headings inside code examples within an entry. Keep the formatted newest entry
|
|||||||
within Discord's 2000-character limit; oversized entries fail without truncation.
|
within Discord's 2000-character limit; oversized entries fail without truncation.
|
||||||
The dated files in `publishing/` remain separate Workshop release notes.
|
The dated files in `publishing/` remain separate Workshop release notes.
|
||||||
|
|
||||||
|
After compiling/bundling and successfully publishing in Workbench, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/workshop_changelog.py --published
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper reads `asset.version` from Workbench's local publishing manifest and
|
||||||
|
updates `Latest published Workshop version: **0.0.109**` above the changelog
|
||||||
|
entries. It verifies the addon ID against `addons/core/addon.gproj`. Use
|
||||||
|
`--manifest /path/to/manifest.json` on another installation. Without `--published`
|
||||||
|
it only displays the bundle version. The flag confirms that you observed a
|
||||||
|
successful upload; the helper does not upload or query the Workshop.
|
||||||
|
|
||||||
|
Commit that record together with the new player-facing entry and source changes,
|
||||||
|
then push. Drone reads the version from Git, without needing Workbench files.
|
||||||
|
Updating only the version does not resend the previous entry. The message labels
|
||||||
|
it as the last published version, since a Git push can contain newer changes.
|
||||||
|
|
||||||
The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the
|
The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the
|
||||||
whole push, even when a documentation commit follows the changelog commit.
|
whole push, even when a documentation commit follows the changelog commit.
|
||||||
Unchanged newest entries, historical edits and removing an entry to expose an
|
Unchanged newest entries, historical edits and removing an entry to expose an
|
||||||
@@ -53,6 +76,8 @@ previous revision; an exact-SHA fetch is attempted for a missing before commit.
|
|||||||
## Verification and delivery failures
|
## Verification and delivery failures
|
||||||
|
|
||||||
Run `python3 -m unittest discover -s tests -p test_discord_changelog_ci.py` locally.
|
Run `python3 -m unittest discover -s tests -p test_discord_changelog_ci.py` locally.
|
||||||
|
Also run `python3 -m unittest discover -s tests -p test_workshop_changelog.py`
|
||||||
|
for the Workbench version sync.
|
||||||
The tests use temporary Git repositories and mocked HTTP; they send no messages.
|
The tests use temporary Git repositories and mocked HTTP; they send no messages.
|
||||||
`python3 tools/discord_changelog_ci.py --dry-run` inside a Drone checkout with its
|
`python3 tools/discord_changelog_ci.py --dry-run` inside a Drone checkout with its
|
||||||
normal commit environment prints the selected message without using the secret.
|
normal commit environment prints the selected message without using the secret.
|
||||||
|
|||||||
@@ -38,3 +38,22 @@ dialog. Select it before publishing.
|
|||||||
|
|
||||||
This directory holds publishing artwork separately from game resources, so the
|
This directory holds publishing artwork separately from game resources, so the
|
||||||
PNG does not need Enfusion texture metadata or inclusion in the runtime PAK.
|
PNG does not need Enfusion texture metadata or inclusion in the runtime PAK.
|
||||||
|
|
||||||
|
## Recording the published version
|
||||||
|
|
||||||
|
The project `.gproj` does not store the Workshop release version. Workbench stores
|
||||||
|
it as `asset.version` in its local publishing `manifest.json`; the top-level
|
||||||
|
`version` is the manifest schema. The latest recorded published version is in
|
||||||
|
the preamble of [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
|
After a successful Workshop upload, run
|
||||||
|
`python3 tools/workshop_changelog.py --published` to copy the version into
|
||||||
|
`CHANGELOG.md`. The default manifest path
|
||||||
|
targets this Linux/Proton Workbench installation; `--manifest PATH` overrides it.
|
||||||
|
Running without `--published` only shows the bundled version. The helper does not
|
||||||
|
publish or verify the online Workshop revision, so compilation alone is not enough
|
||||||
|
to record a release as published.
|
||||||
|
|
||||||
|
Add the player-facing changes to the newest changelog entry, commit all intended
|
||||||
|
source and changelog changes, then push. Drone includes the recorded version in
|
||||||
|
the Discord changelog. See [the notification workflow](../docs/discord-changelog-ci.md).
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ class SelectionTests(unittest.TestCase):
|
|||||||
def test_edit_current_entry(self):
|
def test_edit_current_entry(self):
|
||||||
self.assertIn('Corrected feature.', ci.latest_changes(NEW, NEW.replace('New feature.', 'Corrected feature.')))
|
self.assertIn('Corrected feature.', ci.latest_changes(NEW, NEW.replace('New feature.', 'Corrected feature.')))
|
||||||
|
|
||||||
|
def test_workshop_version_context_and_no_metadata_only_repost(self):
|
||||||
|
with_version = NEW.replace('# Changelog\n', '# Changelog\n\nLatest published Workshop version: **0.0.109**\n')
|
||||||
|
self.assertIn('Last published Workshop version: **0.0.109**', ci.latest_changes(OLD, with_version))
|
||||||
|
self.assertIsNone(ci.latest_changes(NEW, with_version))
|
||||||
|
self.assertIsNone(ci.latest_changes(with_version, with_version.replace('0.0.109', '0.0.110')))
|
||||||
|
for bad in ('not-a-version', '99999.0.0'):
|
||||||
|
with self.assertRaises(ci.ChangelogError):
|
||||||
|
ci.latest_changes(OLD, with_version.replace('0.0.109', bad))
|
||||||
|
|
||||||
|
def test_version_note_counts_toward_discord_limit(self):
|
||||||
|
text = '# Changelog\n\nLatest published Workshop version: **0.0.109**\n\n## A\n' + 'x' * 1993
|
||||||
|
with self.assertRaises(ci.ChangelogError):
|
||||||
|
ci.latest_changes('', text)
|
||||||
|
|
||||||
def test_invalid_entries(self):
|
def test_invalid_entries(self):
|
||||||
for text in ('', '# Changelog', '## Empty\n', '## A\nOne\n## A\nTwo', '## Huge\n' + 'x' * 2000,
|
for text in ('', '# Changelog', '## Empty\n', '## A\nOne\n## A\nTwo', '## Huge\n' + 'x' * 2000,
|
||||||
'## Emoji\n' + '\U0001f680' * 1000):
|
'## Emoji\n' + '\U0001f680' * 1000):
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Version extraction uses the addon asset, not the manifest schema or game version."""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
spec = importlib.util.spec_from_file_location('workshop_changelog', ROOT / 'tools/workshop_changelog.py')
|
||||||
|
tool = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(tool)
|
||||||
|
PROJECT = 'GameProject { GUID "2528466DE220D91C" }'
|
||||||
|
TEXT = '# Changelog\n\n## Update\n\n- Gameplay fix.\n'
|
||||||
|
|
||||||
|
|
||||||
|
class WorkshopTests(unittest.TestCase):
|
||||||
|
def test_asset_version(self):
|
||||||
|
manifest = {'version': 1, 'asset': {'id': '2528466DE220D91C', 'version': '0.0.109', 'gameVersion': '1.8.0.13'}}
|
||||||
|
self.assertEqual(tool.bundled_version(manifest, PROJECT), '0.0.109')
|
||||||
|
|
||||||
|
def test_reject_wrong_addon_and_invalid_manifest(self):
|
||||||
|
for manifest in (None, [], {}, {'asset': None}, {'asset': {'id': None}},
|
||||||
|
{'asset': {'id': 'OTHER', 'version': '0.0.109'}},
|
||||||
|
{'asset': {'id': '2528466DE220D91C', 'version': '99999.0.0'}},
|
||||||
|
{'version': '0.0.109', 'asset': {'id': '2528466DE220D91C'}}):
|
||||||
|
with self.subTest(manifest=manifest), self.assertRaises(ValueError):
|
||||||
|
tool.bundled_version(manifest, PROJECT)
|
||||||
|
|
||||||
|
def test_update_preserves_notes_and_is_idempotent(self):
|
||||||
|
updated = tool.update_changelog(TEXT, '0.0.109')
|
||||||
|
self.assertIn('Latest published Workshop version: **0.0.109**', updated)
|
||||||
|
self.assertTrue(updated.endswith('## Update\n\n- Gameplay fix.\n'))
|
||||||
|
self.assertEqual(updated, tool.update_changelog(updated, '0.0.109'))
|
||||||
|
next_release = tool.update_changelog(updated, '0.0.110')
|
||||||
|
self.assertNotIn('0.0.109', next_release)
|
||||||
|
self.assertEqual(next_release.count('Latest published Workshop version:'), 1)
|
||||||
|
|
||||||
|
def test_bad_layout_refused(self):
|
||||||
|
for text in ('Notes only', TEXT + 'Latest published Workshop version: **0.0.1**\n',
|
||||||
|
'# Changelog\nLatest published Workshop version: **0.0.1**\nLatest published Workshop version: **0.0.2**\n'):
|
||||||
|
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||||
|
tool.update_changelog(text, '0.0.109')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -8,6 +8,7 @@ from prefab_identity import check_prefabs
|
|||||||
ROOT=Path(__file__).resolve().parents[1]
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
BASE='58D0FB3206B6F859'
|
BASE='58D0FB3206B6F859'
|
||||||
def check():
|
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,'-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_controller_init.py')],check=True)
|
||||||
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
|
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
|
||||||
|
|||||||
@@ -1,68 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Post a prepared changelog after verifying a successful push to origin/main.
|
"""Retired local sender: Discord changelogs are delivered exclusively by Drone."""
|
||||||
|
|
||||||
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
|
|
||||||
import sys
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
print('Local Discord sending is disabled. Use the Drone changelog pipeline.', file=sys.stderr)
|
||||||
parser.add_argument('--commit', required=True, help='Commit just pushed to origin/main')
|
return 1
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ def latest_changes(before, after):
|
|||||||
latest = new[0]
|
latest = new[0]
|
||||||
if old and (latest == old[0] or latest[0] in [title for title, _ in old[1:]]):
|
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.
|
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:
|
if len(message.encode('utf-16-le')) // 2 > 2000:
|
||||||
raise ChangelogError('Newest changelog entry exceeds Discord’s 2000-character limit.')
|
raise ChangelogError('Newest changelog entry exceeds Discord’s 2000-character limit.')
|
||||||
return message
|
return message
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user