From 38fd8f7dd2b7446ccc15e15457e63f65b70f6dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Sat, 17 Oct 2026 17:42:39 +0200 Subject: [PATCH] Record Workshop versions in Drone changelogs and retire local sending --- .drone.yml | 1 + AGENTS.md | 57 ++++++++++------------ docs/discord-changelog-ci.md | 33 +++++++++++-- publishing/README.md | 19 ++++++++ tests/test_discord_changelog_ci.py | 14 ++++++ tests/test_workshop_changelog.py | 44 +++++++++++++++++ tools/check.py | 1 + tools/discord_changelog.py | 63 ++----------------------- tools/discord_changelog_ci.py | 10 +++- tools/workshop_changelog.py | 76 ++++++++++++++++++++++++++++++ 10 files changed, 222 insertions(+), 96 deletions(-) create mode 100644 tests/test_workshop_changelog.py create mode 100644 tools/workshop_changelog.py diff --git a/.drone.yml b/.drone.yml index ae24d04..5238b9a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -11,6 +11,7 @@ steps: commands: - 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_workshop_changelog.py - name: notify-discord image: python:3.13-alpine diff --git a/AGENTS.md b/AGENTS.md index 9f9b903..a1b92a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,37 +1,32 @@ # Push changelogs -The user authorized Discord changelogs after new changes are successfully pushed. -Maintain a concise, player-facing newest entry in `CHANGELOG.md` for each update. -The `.drone.yml` pipeline can deliver it automatically for pushes to `main` from -any checkout. See `docs/discord-changelog-ci.md` for activation and secret setup. +Drone is the sole Discord changelog sender. Its first delivery was verified in +build #1 on 2026-10-17, and the user confirmed receipt and disabled local sending. +Do not run a local/manual Discord sender after pushes or as an automatic fallback. +`tools/discord_changelog.py` is disabled and cannot send messages. -On 2026-10-17 the user confirmed Drone is prepared with `discord_webhook_url` -and requested a test push. Drone now owns delivery; do not run the manual helper -for that push or subsequent pushes unless the user explicitly requests a fallback. +For player-facing changes, maintain the newest entry in `CHANGELOG.md` before +pushing to `origin/main`. Keep it concise and describe only fixes/features, without +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 -Drone deliver the changelog and do not also run the manual helper. Check its -delivery result when available; do not claim delivery from Git push success alone. +After a confirmed successful Workbench Workshop upload, run +`python3 tools/workshop_changelog.py --published` before committing the changelog. +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` -from this checkout: +The `.drone.yml` pipeline posts the latest changed entry using Drone's +`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. - Include only the changelog: describe fixes/features accurately, without server, - deployment, or Git push status. Do not add operational disclaimers or footers. - Phrase gameplay fixes without mentioning the server (for example, "Fixed radio - station generation after an empty-session wait"). -2. Save the message to a temporary file and run - `python3 tools/discord_changelog.py --commit --message-file `. -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. +The saved local webhook in `.local/discord-webhook.url` is no longer used for +post-push notifications. Never print it, commit it, or put it into changelogs or +command-line arguments. Do not configure an additional Discord push webhook +alongside Drone. diff --git a/docs/discord-changelog-ci.md b/docs/discord-changelog-ci.md index 9136675..2335b6a 100644 --- a/docs/discord-changelog-ci.md +++ b/docs/discord-changelog-ci.md @@ -1,9 +1,14 @@ # 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, 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 -status added. +entry's heading and body, plus the last recorded published Workshop version when +available, with mentions disabled and no build, Git or hosting status added. ## One-time setup @@ -15,8 +20,8 @@ status added. 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 or install its Gitea webhook. -4. Once Drone delivery is enabled, stop the manual post-push notifier. Both - delivery paths running for the same push would produce duplicate messages. +4. Keep local/manual notification sending disabled. Do not add a second sender + or automatically fall back to local delivery after a failed Drone build. Drone's repository activation manages the Gitea event webhook. This notification 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. 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 whole push, even when a documentation commit follows the changelog commit. 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 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. `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. diff --git a/publishing/README.md b/publishing/README.md index cded9ee..191330d 100644 --- a/publishing/README.md +++ b/publishing/README.md @@ -38,3 +38,22 @@ dialog. Select it before publishing. This directory holds publishing artwork separately from game resources, so the 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). diff --git a/tests/test_discord_changelog_ci.py b/tests/test_discord_changelog_ci.py index a155f96..320fb42 100644 --- a/tests/test_discord_changelog_ci.py +++ b/tests/test_discord_changelog_ci.py @@ -35,6 +35,20 @@ class SelectionTests(unittest.TestCase): def test_edit_current_entry(self): 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): for text in ('', '# Changelog', '## Empty\n', '## A\nOne\n## A\nTwo', '## Huge\n' + 'x' * 2000, '## Emoji\n' + '\U0001f680' * 1000): diff --git a/tests/test_workshop_changelog.py b/tests/test_workshop_changelog.py new file mode 100644 index 0000000..06523ed --- /dev/null +++ b/tests/test_workshop_changelog.py @@ -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() diff --git a/tools/check.py b/tools/check.py index 4d61f1e..2ad855e 100644 --- a/tools/check.py +++ b/tools/check.py @@ -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) diff --git a/tools/discord_changelog.py b/tools/discord_changelog.py index 0422701..6c6dd68 100644 --- a/tools/discord_changelog.py +++ b/tools/discord_changelog.py @@ -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__': diff --git a/tools/discord_changelog_ci.py b/tools/discord_changelog_ci.py index d4867ec..29b0022 100644 --- a/tools/discord_changelog_ci.py +++ b/tools/discord_changelog_ci.py @@ -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 diff --git a/tools/workshop_changelog.py b/tools/workshop_changelog.py new file mode 100644 index 0000000..d22ccfc --- /dev/null +++ b/tools/workshop_changelog.py @@ -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())