mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Record every published Manager version in its own changelog (#137)
This commit is contained in:
1 parent
43de03eada
commit
20fb2391ed
6 files changed
+317
-39
No files matched your search
@@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
paths: [tools/sync_history.py, tools/test_sync_history.py, changelog/history.json, .github/workflows/changelog.yml]
|
||||
release:
|
||||
types: [published, released]
|
||||
types: [published, prereleased, released]
|
||||
workflow_run:
|
||||
workflows: [release]
|
||||
types: [completed]
|
||||
|
||||
@@ -31,6 +31,10 @@ Optional: one short paragraph in plain English for the reader (at most 300 chara
|
||||
|
||||
## Shared build and release workflow
|
||||
|
||||
Every published Manager version, including a clearly marked Pre-release, belongs in this repository's
|
||||
CHANGELOG.md and changelog/history.json. Its GitHub release notes use the same reviewed record.
|
||||
Promotion updates that entry without duplicating it; Manager and server histories remain separate.
|
||||
|
||||
Before preparing or publishing a Manager/server release, read the maintained
|
||||
[release runbook](https://github.com/Corfirean/coa-server-build/blob/main/docs/server-release.md)
|
||||
and [build agent instructions](https://github.com/Corfirean/coa-server-build/blob/main/AGENTS.md).
|
||||
|
||||
@@ -4,8 +4,29 @@ Manager releases only. Updating the Manager does not update the server.
|
||||
|
||||
[Server changelog](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md)
|
||||
|
||||
Published releases and previews. Pre-release entries do not change the stable update channel.
|
||||
|
||||
Dates follow GitHub publication (UTC); legacy records retain their archived dates. Newest releases first.
|
||||
|
||||
## 0.7.0 (Pre-release) — 2026-10-21
|
||||
|
||||
Portable transfers into Warcraft Reborn are not supported yet.
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Keep portable characters and their saved versions in the Manager, and use them on compatible realms.
|
||||
- Make an existing character portable from a connected realm.
|
||||
- Browse public realms and see their connection details.
|
||||
- Link an existing realm account or create one on hosts that allow it.
|
||||
- Switch between CoA, Wildcard and Warcraft Reborn realm profiles on supported server builds.
|
||||
- Adjust creature health for different locations and enemy types in Content Scaling.
|
||||
- Switch Custom Races patches faster, with visible feedback while changes are applied.
|
||||
- Handle Windows requests for administrator permission when launching the game.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.0) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.6.22...v0.7.0)
|
||||
|
||||
[Player guides and screenshots](https://github.com/Corfirean/coa-server-manager/tree/d22ff09ac6fd7628aac14d90ec23f547cd9b4bf1/docs)
|
||||
|
||||
## 0.6.22 — 2026-10-19
|
||||
|
||||
- Updated Custom Races client patches to 1.8.
|
||||
|
||||
@@ -571,6 +571,151 @@
|
||||
"Existing client files are preserved when installing or removing race patches"
|
||||
],
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.6.21...v0.6.22"
|
||||
},
|
||||
{
|
||||
"tag": "v0.7.0",
|
||||
"version": "0.7.0",
|
||||
"date": "2026-10-21",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.0",
|
||||
"notes": [
|
||||
"Keep portable characters and their saved versions in the Manager, and use them on compatible realms",
|
||||
"Make an existing character portable from a connected realm",
|
||||
"Browse public realms and see their connection details",
|
||||
"Link an existing realm account or create one on hosts that allow it",
|
||||
"Switch between CoA, Wildcard and Warcraft Reborn realm profiles on supported server builds",
|
||||
"Adjust creature health for different locations and enemy types in Content Scaling",
|
||||
"Switch Custom Races patches faster, with visible feedback while changes are applied",
|
||||
"Handle Windows requests for administrator permission when launching the game"
|
||||
],
|
||||
"commit": "d22ff09ac6fd7628aac14d90ec23f547cd9b4bf1",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
],
|
||||
"prerelease": true,
|
||||
"fragments": [
|
||||
"Corfirean/coa-server-manager:20261002-collections-tab-with-transmog-vanity-riding-and.md",
|
||||
"Corfirean/coa-server-manager:20261002-companion-bots-are-recognised-from-their-default.md",
|
||||
"Corfirean/coa-server-manager:20261002-delete-an-account-from-the-players-page.md",
|
||||
"Corfirean/coa-server-manager:20261002-importing-a-server-suggests-the-right-folder-whe.md",
|
||||
"Corfirean/coa-server-manager:20261002-modules-page-with-cards-status-badges-and-per-mo.md",
|
||||
"Corfirean/coa-server-manager:20261002-report-a-problem-collects-more-and-ignores-repea.md",
|
||||
"Corfirean/coa-server-manager:20261002-server-settings-in-one-panel-with-search-tabs-an.md",
|
||||
"Corfirean/coa-server-manager:20261002-the-update-check-says-you-are-up-to-date-instead.md",
|
||||
"Corfirean/coa-server-manager:20261003-client-realm-sync.md",
|
||||
"Corfirean/coa-server-manager:20261003-config-snapshot-restore.md",
|
||||
"Corfirean/coa-server-manager:20261003-imported-servers-get-the-module-config-files-the.md",
|
||||
"Corfirean/coa-server-manager:20261003-linux-preview.md",
|
||||
"Corfirean/coa-server-manager:20261003-modules-marked-soon-stay-greyed-out.md",
|
||||
"Corfirean/coa-server-manager:20261003-report-browser.md",
|
||||
"Corfirean/coa-server-manager:20261003-simplified-chinese.md",
|
||||
"Corfirean/coa-server-manager:20261003-the-bots-page-is-hidden-when-the-module-is-not-o.md",
|
||||
"Corfirean/coa-server-manager:20261003-updating-the-game-client-shows-a-progress-bar-on.md",
|
||||
"Corfirean/coa-server-manager:20261003-wildcard-realm-profiles.md",
|
||||
"Corfirean/coa-server-manager:20261004-auction-item-types.md",
|
||||
"Corfirean/coa-server-manager:20261004-fractional-settings.md",
|
||||
"Corfirean/coa-server-manager:20261004-independent-client-launch.md",
|
||||
"Corfirean/coa-server-manager:20261004-module-settings.md",
|
||||
"Corfirean/coa-server-manager:20261004-recover-server-updates.md",
|
||||
"Corfirean/coa-server-manager:20261004-repair-and-database-validation.md",
|
||||
"Corfirean/coa-server-manager:20261004-scaling-lfg-leech-settings.md",
|
||||
"Corfirean/coa-server-manager:20261004-simultaneous-realms.md",
|
||||
"Corfirean/coa-server-manager:20261004-squid-playerbots.md",
|
||||
"Corfirean/coa-server-manager:20261005-connect-without-server.md",
|
||||
"Corfirean/coa-server-manager:20261005-imported-squid-warning.md",
|
||||
"Corfirean/coa-server-manager:20261005-launch-without-updating.md",
|
||||
"Corfirean/coa-server-manager:20261005-linux-docker-server-installer.md",
|
||||
"Corfirean/coa-server-manager:20261005-linux-docker-server-settings.md",
|
||||
"Corfirean/coa-server-manager:20261005-linux-install-screen-and-client.md",
|
||||
"Corfirean/coa-server-manager:20261005-retry-update-startup.md",
|
||||
"Corfirean/coa-server-manager:20261005-squid-release-metadata.md",
|
||||
"Corfirean/coa-server-manager:20261005-squid-version-reports.md",
|
||||
"Corfirean/coa-server-manager:20261005-update-history-hotfix.md",
|
||||
"Corfirean/coa-server-manager:20261005-update-legacy-realm-launcher.md",
|
||||
"Corfirean/coa-server-manager:20261005-validate-release-database.md",
|
||||
"Corfirean/coa-server-manager:20261006-ah-bot-needs-no-character-guid.md",
|
||||
"Corfirean/coa-server-manager:20261006-lan-address-selector.md",
|
||||
"Corfirean/coa-server-manager:20261006-npc-enchanter-placement-hint.md",
|
||||
"Corfirean/coa-server-manager:20261006-prevent-server-downgrades.md",
|
||||
"Corfirean/coa-server-manager:20261006-recognize-managed-launcher.md",
|
||||
"Corfirean/coa-server-manager:20261006-squid-first-start-timeout.md",
|
||||
"Corfirean/coa-server-manager:20261006-update-preflight-and-recovery-guards.md",
|
||||
"Corfirean/coa-server-manager:20261006-update-sql-and-status-guards.md",
|
||||
"Corfirean/coa-server-manager:20261007-diagnostics-valid-json.md",
|
||||
"Corfirean/coa-server-manager:20261007-one-click-diagnostics.md",
|
||||
"Corfirean/coa-server-manager:20261007-report-routing.md",
|
||||
"Corfirean/coa-server-manager:20261008-backup-folder-and-bot-choices.md",
|
||||
"Corfirean/coa-server-manager:20261008-bot-settings-drop-down.md",
|
||||
"Corfirean/coa-server-manager:20261008-dashboard-delivery.md",
|
||||
"Corfirean/coa-server-manager:20261008-database-error-not-misread.md",
|
||||
"Corfirean/coa-server-manager:20261008-hide-squid-bot-accounts.md",
|
||||
"Corfirean/coa-server-manager:20261008-squid-dashboard-tab.md",
|
||||
"Corfirean/coa-server-manager:20261008-start-after-update-options.md",
|
||||
"Corfirean/coa-server-manager:20261008-update-check-survives-damaged-state.md",
|
||||
"Corfirean/coa-server-manager:20261008-wildcard-repair-checksum.md",
|
||||
"Corfirean/coa-server-manager:20261008-world-server-from-coa-bots-folder.md",
|
||||
"Corfirean/coa-server-manager:20261010-automatic-update-recovery.md",
|
||||
"Corfirean/coa-server-manager:20261010-complete-mysql-update-recovery.md",
|
||||
"Corfirean/coa-server-manager:20261010-custom-races-bot-exclusions.md",
|
||||
"Corfirean/coa-server-manager:20261010-custom-races-update.md",
|
||||
"Corfirean/coa-server-manager:20261010-custom-races.md",
|
||||
"Corfirean/coa-server-manager:20261010-database-recovery-diagnostics.md",
|
||||
"Corfirean/coa-server-manager:20261010-headless-world-startup.md",
|
||||
"Corfirean/coa-server-manager:20261010-immutable-update-channels.md",
|
||||
"Corfirean/coa-server-manager:20261010-isolated-update-validation.md",
|
||||
"Corfirean/coa-server-manager:20261010-local-host-while-sharing.md",
|
||||
"Corfirean/coa-server-manager:20261010-missing-database-defaults.md",
|
||||
"Corfirean/coa-server-manager:20261010-module-base-update-replay.md",
|
||||
"Corfirean/coa-server-manager:20261010-private-database-update-rehearsal.md",
|
||||
"Corfirean/coa-server-manager:20261010-release-compatibility.md",
|
||||
"Corfirean/coa-server-manager:20261010-schema-validation-report.md",
|
||||
"Corfirean/coa-server-manager:20261010-settings-blank-window.md",
|
||||
"Corfirean/coa-server-manager:20261010-unsaved-changes-and-crash-recovery.md",
|
||||
"Corfirean/coa-server-manager:20261011-player-race-patches.md",
|
||||
"Corfirean/coa-server-manager:20261012-bulk-navigation-update.md",
|
||||
"Corfirean/coa-server-manager:20261012-rehearsal-path-normalization.md",
|
||||
"Corfirean/coa-server-manager:20261013-custom-races-client-update.md",
|
||||
"Corfirean/coa-server-manager:20261013-docker-remote-client-restart.md",
|
||||
"Corfirean/coa-server-manager:20261013-installed-module-versions.md",
|
||||
"Corfirean/coa-server-manager:20261013-long-rehearsal-paths.md",
|
||||
"Corfirean/coa-server-manager:20261013-maintenance-diagnostic-export.md",
|
||||
"Corfirean/coa-server-manager:20261013-preserve-client-patches.md",
|
||||
"Corfirean/coa-server-manager:20261013-recheck-installed-release.md",
|
||||
"Corfirean/coa-server-manager:20261013-startup-error-details.md",
|
||||
"Corfirean/coa-server-manager:20261013-stock-bot-races.md",
|
||||
"Corfirean/coa-server-manager:20261013-update-check-start-contention.md",
|
||||
"Corfirean/coa-server-manager:20261013-upstream-bot-race-preset.md",
|
||||
"Corfirean/coa-server-manager:20261014-already-running-mysql-is-recognized-before-start.md",
|
||||
"Corfirean/coa-server-manager:20261014-current-clean-install.md",
|
||||
"Corfirean/coa-server-manager:20261014-custom-races-grummle-client.md",
|
||||
"Corfirean/coa-server-manager:20261014-failed-repairs-restore-the-previous-server-after.md",
|
||||
"Corfirean/coa-server-manager:20261014-overview-update-errors.md",
|
||||
"Corfirean/coa-server-manager:20261014-private-update-checks-show-preparation-stages-an.md",
|
||||
"Corfirean/coa-server-manager:20261014-repair-recovery-copies-preserve-all-bundled-data.md",
|
||||
"Corfirean/coa-server-manager:20261014-server-checks-report-interrupted-squid-database.md",
|
||||
"Corfirean/coa-server-manager:20261014-shutdown-remains-available-when-startup-template.md",
|
||||
"Corfirean/coa-server-manager:20261014-successful-server-repairs-no-longer-block-later.md",
|
||||
"Corfirean/coa-server-manager:20261016-consistent-update-migrations.md",
|
||||
"Corfirean/coa-server-manager:20261016-direct-database-upgrades.md",
|
||||
"Corfirean/coa-server-manager:20261016-idempotent-validation-stop.md",
|
||||
"Corfirean/coa-server-manager:20261016-intermediate-updates.md",
|
||||
"Corfirean/coa-server-manager:20261016-lightweight-update-checks.md",
|
||||
"Corfirean/coa-server-manager:20261016-preserve-core-migration-history.md",
|
||||
"Corfirean/coa-server-manager:20261016-shared-auth-migration-history.md",
|
||||
"Corfirean/coa-server-manager:20261016-update-compatibility-wording.md",
|
||||
"Corfirean/coa-server-manager:20261018-custom-races-client.md",
|
||||
"Corfirean/coa-server-manager:20261019-portable-stop-diagnostics.md",
|
||||
"Corfirean/coa-server-manager:20261019-remote-character-class-names.md",
|
||||
"Corfirean/coa-server-manager:20261020-client-elevation.md",
|
||||
"Corfirean/coa-server-manager:20261021-custom-races-switching.md",
|
||||
"Corfirean/coa-server-manager:20261021-portable-experimental.md",
|
||||
"Corfirean/coa-server-manager:20261021-scaling-health-settings.md",
|
||||
"Corfirean/coa-server-manager:20261021-warcraft-reborn-realm.md"
|
||||
],
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.6.22...v0.7.0",
|
||||
"notice": "Portable transfers into Warcraft Reborn are not supported yet.",
|
||||
"links": [
|
||||
"[Player guides and screenshots](https://github.com/Corfirean/coa-server-manager/tree/d22ff09ac6fd7628aac14d90ec23f547cd9b4bf1/docs)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+72
-36
@@ -32,7 +32,7 @@ def version_key(version):
|
||||
|
||||
def eligible(release, component):
|
||||
pattern = r'v(\d+(?:\.\d+)+)' if component == 'manager' else r'server-(\d+(?:\.\d+)+)'
|
||||
return not release['draft'] and not release['prerelease'] and re.fullmatch(pattern, release['tag_name'])
|
||||
return not release['draft'] and re.fullmatch(pattern, release['tag_name'])
|
||||
|
||||
|
||||
def published(repo, component):
|
||||
@@ -58,7 +58,7 @@ def fragment_note(text, areas):
|
||||
return title
|
||||
|
||||
|
||||
def remote_fragments(repo, sha, seen, areas, excluded_blobs=()):
|
||||
def remote_fragments(repo, sha, seen, areas, excluded_blobs=(), overrides=None):
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', sha):
|
||||
raise ValueError('Changelog collection requires an immutable source SHA')
|
||||
listing = api(f'repos/{repo}/contents/changelog.d?ref={sha}')
|
||||
@@ -66,8 +66,16 @@ def remote_fragments(repo, sha, seen, areas, excluded_blobs=()):
|
||||
new = [f for f in files if repo + ':' + f['name'] not in seen and f['sha'] not in excluded_blobs]
|
||||
|
||||
def read(item):
|
||||
reviewed = (overrides or {}).get(repo + ':' + item['sha'])
|
||||
if reviewed is not None:
|
||||
if not isinstance(reviewed, str) or not reviewed.strip():
|
||||
raise ValueError('Reviewed fragment override needs a player-facing title')
|
||||
return reviewed
|
||||
blob = api(f'repos/{repo}/git/blobs/{item["sha"]}')
|
||||
return fragment_note(base64.b64decode(blob['content']).decode('utf-8'), areas)
|
||||
try:
|
||||
return fragment_note(base64.b64decode(blob['content']).decode('utf-8'), areas)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{repo}:{item["name"]}@{sha}: {error}') from error
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
notes = [note for note in pool.map(read, new) if note]
|
||||
@@ -86,7 +94,10 @@ def collect_manager(root, tag, seen):
|
||||
note = fragment_note(text, MANAGER_AREAS)
|
||||
if note:
|
||||
notes.append(note)
|
||||
return notes, [repo + ':' + Path(p).name for p in paths], {'commit': sha}
|
||||
source = {'commit': sha}
|
||||
if any('portable' in note.lower() and 'experimental' in note.lower() for note in notes):
|
||||
source['experimentalFeatures'] = ['portable-characters']
|
||||
return notes, [repo + ':' + Path(p).name for p in paths], source
|
||||
|
||||
|
||||
def upstream_fragment_blobs(lock):
|
||||
@@ -102,7 +113,7 @@ def upstream_fragment_blobs(lock):
|
||||
return {item['sha'] for item in files if item['type'] == 'blob' and re.fullmatch(r'\d{8}-[a-z0-9-]+\.md', item['path'])}
|
||||
|
||||
|
||||
def collect_server(repo, tag, seen, previous):
|
||||
def collect_server(repo, tag, seen, previous, overrides=None):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
subprocess.run(['gh', 'release', 'download', tag, '--repo', repo, '--pattern', 'release-lock.json', '--dir', folder], check=True)
|
||||
lock = json.loads((Path(folder) / 'release-lock.json').read_text(encoding='utf-8-sig'))
|
||||
@@ -114,7 +125,8 @@ def collect_server(repo, tag, seen, previous):
|
||||
raise ValueError('Release lock has no ' + role + ' source')
|
||||
if item['repository'] == 'Corfirean/coa-server-manager':
|
||||
raise ValueError('A Manager source cannot supply server release notes')
|
||||
entries, found = remote_fragments(item['repository'], item['sha'], seen, SERVER_AREAS, imported_blobs if role == 'core' else ())
|
||||
entries, found = remote_fragments(item['repository'], item['sha'], seen, SERVER_AREAS,
|
||||
imported_blobs if role == 'core' else (), overrides)
|
||||
notes.extend(entries)
|
||||
ids.extend(found)
|
||||
old = previous.get('components', {})
|
||||
@@ -128,16 +140,24 @@ def collect_server(repo, tag, seen, previous):
|
||||
notes.append(f'Updated {label} to {version}')
|
||||
if lock['components'].get('upstream', {}).get('sha') != old.get('upstream', {}).get('sha'):
|
||||
notes.append('Updated the reviewed CoA upstream snapshot')
|
||||
return list(dict.fromkeys(notes)), ids, {'snapshot': lock['snapshot'], 'components': lock['components']}
|
||||
return list(dict.fromkeys(notes)), ids, {'snapshot': lock['snapshot'], 'components': lock['components'],
|
||||
'experimentalFeatures': lock.get('experimentalFeatures', [])}
|
||||
|
||||
|
||||
def notes_md(record):
|
||||
lines = [f'## {record["version"]} — {record["date"]}', '']
|
||||
lines.extend('- ' + note.rstrip('.') + '.' for note in record['notes'])
|
||||
status = ' (Pre-release)' if record.get('prerelease') else ''
|
||||
lines = [f'## {record["version"]}{status} — {record["date"]}', '']
|
||||
if record.get('notice'):
|
||||
lines += [record['notice'], '']
|
||||
if 'portable-characters' in record.get('experimentalFeatures', []):
|
||||
lines += ['**Portable Characters are experimental and may contain bugs.**', '']
|
||||
lines.extend('- ' + note.strip().rstrip('.') + '.' for note in record['notes'])
|
||||
label = 'Archived release record' if record.get('legacy') else 'Release'
|
||||
lines += ['', f'[{label}]({record["url"]})']
|
||||
if record.get('compare'):
|
||||
lines[-1] += f' · [Source changes]({record["compare"]})'
|
||||
if record.get('links'):
|
||||
lines += ['', *(link.strip() for link in record['links'])]
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
@@ -149,7 +169,8 @@ def render(history):
|
||||
lines = [f'# {title} changelog', '',
|
||||
('Manager releases only. Updating the Manager does not update the server.' if component == 'manager'
|
||||
else 'Server packages, companions and server modules only. Manager releases have their own changelog.'),
|
||||
'', other, '', 'Dates follow GitHub publication (UTC); legacy records retain their archived dates. Newest releases first.', '']
|
||||
'', other, '', 'Published releases and previews. Pre-release entries do not change the stable update channel.', '',
|
||||
'Dates follow GitHub publication (UTC); legacy records retain their archived dates. Newest releases first.', '']
|
||||
for record in sorted(history['releases'], key=lambda r: (r['date'], version_key(r['version'])), reverse=True):
|
||||
lines.append(notes_md(record))
|
||||
return '\n'.join(lines).rstrip() + '\n'
|
||||
@@ -175,6 +196,45 @@ def validate_history(history):
|
||||
raise ValueError('Server history cannot consume Manager fragments')
|
||||
|
||||
|
||||
def reconcile(history, releases, root):
|
||||
component, repo = history['component'], history['repository']
|
||||
records = {r['tag']: r for r in history['releases']}
|
||||
seen = set(history.get('consumedFragments', []))
|
||||
for release in releases:
|
||||
tag = release['tag_name']
|
||||
preview = bool(release.get('prerelease'))
|
||||
if tag in records:
|
||||
record = records[tag]
|
||||
if preview:
|
||||
record['prerelease'] = True
|
||||
elif record.pop('prerelease', False):
|
||||
seen.update(record.pop('fragments', []))
|
||||
continue
|
||||
version = tag.removeprefix('server-').removeprefix('v')
|
||||
older = [r for r in history['releases'] if not r.get('prerelease')
|
||||
and version_key(r['version']) < version_key(version)]
|
||||
previous = max(older, key=lambda r: version_key(r['version']), default={})
|
||||
if component == 'manager':
|
||||
notes, ids, source = collect_manager(root, tag, frozenset(seen))
|
||||
else:
|
||||
notes, ids, source = collect_server(repo, tag, frozenset(seen), previous,
|
||||
history.get('fragmentOverrides'))
|
||||
if not notes:
|
||||
notes = ['No additional player-facing changes were recorded for this build']
|
||||
record = {'tag': tag, 'version': version, 'date': release['published_at'][:10],
|
||||
'url': release['html_url'], 'notes': notes, **source}
|
||||
if preview:
|
||||
record.update(prerelease=True, fragments=ids)
|
||||
else:
|
||||
seen.update(ids)
|
||||
if previous.get('tag'):
|
||||
record['compare'] = f'https://github.com/{repo}/compare/{previous["tag"]}...{tag}'
|
||||
history['releases'].append(record)
|
||||
records[tag] = record
|
||||
history['consumedFragments'] = sorted(seen)
|
||||
validate_history(history)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', type=Path, default=Path.cwd())
|
||||
@@ -188,32 +248,8 @@ def main():
|
||||
component, repo = history['component'], history['repository']
|
||||
if component not in ('server', 'manager'):
|
||||
raise ValueError('History must belong to one component')
|
||||
existing = {r['tag'] for r in history['releases']}
|
||||
seen = set(history.get('consumedFragments', []))
|
||||
releases = [] if args.render_only else published(repo, component)
|
||||
previous = sorted(history['releases'], key=lambda r: (r['date'], version_key(r['version'])))[-1] if existing else {}
|
||||
for release in releases:
|
||||
tag = release['tag_name']
|
||||
if tag in existing:
|
||||
continue
|
||||
version = tag.removeprefix('server-').removeprefix('v')
|
||||
if previous and version_key(version) <= version_key(previous['version']):
|
||||
raise ValueError('Historical backfill requires a reviewed history correction: ' + tag)
|
||||
if component == 'manager':
|
||||
notes, ids, source = collect_manager(args.root, tag, seen)
|
||||
else:
|
||||
notes, ids, source = collect_server(repo, tag, seen, previous)
|
||||
if not notes:
|
||||
notes = ['Detailed player-facing notes were not recorded; see the source changes for this release']
|
||||
record = {'tag': tag, 'version': tag.removeprefix('server-').removeprefix('v'),
|
||||
'date': release['published_at'][:10], 'url': release['html_url'], 'notes': notes, **source}
|
||||
if previous.get('tag'):
|
||||
record['compare'] = f'https://github.com/{repo}/compare/{previous["tag"]}...{tag}'
|
||||
history['releases'].append(record)
|
||||
seen.update(ids)
|
||||
previous = record
|
||||
history['consumedFragments'] = sorted(seen)
|
||||
validate_history(history)
|
||||
reconcile(history, releases, args.root)
|
||||
# Keep a separately reviewed history as the source of truth for historical corrections.
|
||||
path.write_text(json.dumps(history, indent=2, ensure_ascii=False) + '\n', encoding='utf8', newline='\n')
|
||||
(args.root / 'CHANGELOG.md').write_text(render(history), encoding='utf8', newline='\n')
|
||||
@@ -222,7 +258,7 @@ def main():
|
||||
for record in history['releases']:
|
||||
release = actual.get(record['tag'])
|
||||
if not release:
|
||||
continue # Never create a missing historical release or touch a prerelease.
|
||||
continue # Never create a missing historical release.
|
||||
body = notes_md(record)
|
||||
if (release.get('body') or '').strip() != body.strip():
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
|
||||
@@ -28,16 +28,68 @@ class ComponentHistoryTests(unittest.TestCase):
|
||||
history['releases'][0]['tag'] = 'server-0.6.22'
|
||||
with self.assertRaises(ValueError): sync.validate_history(history)
|
||||
|
||||
def test_unpublished_and_prerelease_tags_never_enter_history(self):
|
||||
def test_published_previews_are_included_but_drafts_and_other_components_are_not(self):
|
||||
release = {'tag_name': 'v0.7.0', 'draft': True, 'prerelease': False}
|
||||
self.assertFalse(sync.eligible(release, 'manager'))
|
||||
release.update(draft=False, prerelease=True)
|
||||
self.assertFalse(sync.eligible(release, 'manager'))
|
||||
self.assertTrue(sync.eligible(release, 'manager'))
|
||||
release.update(prerelease=False)
|
||||
self.assertTrue(sync.eligible(release, 'manager'))
|
||||
self.assertFalse(sync.eligible(release, 'server'))
|
||||
self.assertFalse(sync.eligible(dict(release, tag_name='stable'), 'server'))
|
||||
|
||||
def release(self, version='0.7.0', preview=True):
|
||||
return {'tag_name': 'v' + version, 'prerelease': preview, 'draft': False,
|
||||
'published_at': '2026-10-22T12:00:00Z', 'html_url': 'https://example.test/v' + version}
|
||||
|
||||
def test_preview_does_not_consume_stable_changes_and_repeated_sync_is_idempotent(self):
|
||||
history = self.history()
|
||||
with patch.object(sync, 'collect_manager', return_value=(['Portable Characters are experimental'], ['own:portable'], {'commit': 'a' * 40})) as collect:
|
||||
sync.reconcile(history, [self.release()], Path('.'))
|
||||
sync.reconcile(history, [self.release()], Path('.'))
|
||||
self.assertEqual(collect.call_count, 1)
|
||||
self.assertEqual(history['consumedFragments'], [])
|
||||
self.assertEqual(len(history['releases']), 2)
|
||||
self.assertTrue(history['releases'][-1]['prerelease'])
|
||||
self.assertIn('## 0.7.0 (Pre-release)', sync.render(history))
|
||||
|
||||
def test_promotion_updates_the_same_reviewed_record_without_collecting_or_duplicating(self):
|
||||
history = self.history()
|
||||
with patch.object(sync, 'collect_manager', return_value=(['Portable Characters are experimental'], ['own:portable'], {'commit': 'a' * 40})):
|
||||
sync.reconcile(history, [self.release()], Path('.'))
|
||||
history['releases'][-1]['notes'] = ['Reviewed player-facing text']
|
||||
with patch.object(sync, 'collect_manager', side_effect=AssertionError('Frozen notes already recorded')):
|
||||
sync.reconcile(history, [self.release(preview=False)], Path('.'))
|
||||
sync.reconcile(history, [self.release(preview=False)], Path('.'))
|
||||
self.assertEqual(len(history['releases']), 2)
|
||||
self.assertNotIn('prerelease', history['releases'][-1])
|
||||
self.assertEqual(history['consumedFragments'], ['own:portable'])
|
||||
self.assertEqual(history['releases'][-1]['notes'], ['Reviewed player-facing text'])
|
||||
|
||||
def test_stable_hotfix_after_higher_preview_keeps_its_own_notes(self):
|
||||
history = self.history()
|
||||
with patch.object(sync, 'collect_manager', return_value=(['Shared improvement'], ['own:shared'], {'commit': 'a' * 40})):
|
||||
sync.reconcile(history, [self.release()], Path('.'))
|
||||
with patch.object(sync, 'collect_manager', return_value=(['Shared improvement'], ['own:shared'], {'commit': 'b' * 40})) as collect:
|
||||
sync.reconcile(history, [self.release('0.6.23', preview=False), self.release()], Path('.'))
|
||||
self.assertEqual(collect.call_args.args[2], set())
|
||||
hotfix = next(r for r in history['releases'] if r['version'] == '0.6.23')
|
||||
self.assertEqual(hotfix['notes'], ['Shared improvement'])
|
||||
self.assertEqual(history['consumedFragments'], ['own:shared'])
|
||||
|
||||
def test_server_preview_uses_only_server_locked_sources_and_keeps_notice(self):
|
||||
history = {'schema': 1, 'component': 'server', 'repository': 'Corfirean/coa-server-build',
|
||||
'consumedFragments': [], 'releases': []}
|
||||
release = dict(self.release(), tag_name='server-0.261022.75')
|
||||
with patch.object(sync, 'collect_server', return_value=(['Experimental Portable Characters support'], ['core:portable'], {'snapshot': 'a' * 64})), \
|
||||
patch.object(sync, 'collect_manager', side_effect=AssertionError('Wrong component')):
|
||||
sync.reconcile(history, [release], Path('.'))
|
||||
history['releases'][0]['notice'] = 'Portable Characters are experimental.'
|
||||
text = sync.render(history)
|
||||
self.assertIn('## 0.261022.75 (Pre-release)', text)
|
||||
self.assertIn('Portable Characters are experimental.', text)
|
||||
self.assertEqual(history['consumedFragments'], [])
|
||||
|
||||
def test_fragment_classification_preserves_source_boundaries(self):
|
||||
text = '---\narea: manager\ntitle: Updates recover safely\n---\nYour files are preserved.'
|
||||
self.assertIsNone(sync.fragment_note(text, sync.SERVER_AREAS))
|
||||
@@ -86,6 +138,19 @@ class ComponentHistoryTests(unittest.TestCase):
|
||||
self.assertEqual(calls.call_count, 2)
|
||||
self.assertTrue(calls.call_args.args[0].endswith('/git/blobs/own'))
|
||||
|
||||
def test_reviewed_legacy_fragment_override_is_bound_to_exact_blob(self):
|
||||
import base64
|
||||
listing = [{'name': '20261006-legacy.md', 'sha': 'legacy-blob'}]
|
||||
with patch.object(sync, 'api', return_value=listing) as calls:
|
||||
notes, _ = sync.remote_fragments('owner/core', 'a' * 40, set(), sync.SERVER_AREAS,
|
||||
overrides={'owner/core:legacy-blob': 'Bots retain their combat form'})
|
||||
self.assertEqual(notes, ['Bots retain their combat form'])
|
||||
self.assertEqual(calls.call_count, 1)
|
||||
with patch.object(sync, 'api', side_effect=[listing, {'content': base64.b64encode(b'Malformed new note').decode()}]):
|
||||
with self.assertRaisesRegex(ValueError, 'owner/core:20261006-legacy.md'):
|
||||
sync.remote_fragments('owner/core', 'a' * 40, set(), sync.SERVER_AREAS,
|
||||
overrides={'owner/core:different-blob': 'Must not hide malformed notes'})
|
||||
|
||||
def test_upstream_exclusions_use_the_frozen_source_tree(self):
|
||||
lock = {'components': {'upstream': {'repository': 'upstream/core', 'sha': 'frozen'}}}
|
||||
with patch.object(sync, 'api', side_effect=[{'tree': [{'path': 'changelog.d', 'type': 'tree', 'sha': 'notes-tree'}]}, {'tree': [{'path': '20261021-imported.md', 'type': 'blob', 'sha': 'imported'}, {'path': 'README.md', 'type': 'blob', 'sha': 'readme'}]}]) as calls:
|
||||
@@ -100,6 +165,13 @@ class ComponentHistoryTests(unittest.TestCase):
|
||||
self.assertNotIn('## 0.7.0', text)
|
||||
self.assertEqual(text, sync.render(self.history()))
|
||||
|
||||
def test_reviewed_windows_release_text_renders_without_carriage_returns(self):
|
||||
record = self.history()['releases'][0]
|
||||
record.update(notes=['Existing files are preserved\r'], links=['[Guide](https://example.test/guide)\r'])
|
||||
text = sync.notes_md(record)
|
||||
self.assertIn('- Existing files are preserved.\n', text)
|
||||
self.assertNotIn('\r', text)
|
||||
|
||||
def test_render_only_does_not_contact_github_or_change_published_history(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder); (root / 'changelog').mkdir()
|
||||
|
||||
Reference in new issue
Block a user