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: 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
+26 -31
View File
@@ -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.
+29 -4
View File
@@ -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.
+19
View File
@@ -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).
+14
View File
@@ -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):
+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] 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)
+3 -60
View File
@@ -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__':
+9 -1
View File
@@ -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
+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())