Record every published Manager version in its own changelog (#137)

This commit is contained in:
Corfirean authored and GitHub committed 2026-10-22 07:49:15 +05:00
1 parent 43de03eada
commit 20fb2391ed
6 files changed
+317 -39

No files matched your search

+1 -1
View File
@@ -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]
+4
View File
@@ -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).
+21
View File
@@ -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.
+145
View File
@@ -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
View File
@@ -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:
+74 -2
View File
@@ -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()