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
@@ -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
+26 -31
View File
@@ -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 <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.
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.
+29 -4
View File
@@ -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.
+19
View File
@@ -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).
+14
View File
@@ -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):
+44
View File
@@ -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()
+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)
+2 -59
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)
print('Local Discord sending is disabled. Use the Drone changelog pipeline.', 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__':
+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())