From 20fb2391ed69cf8098727e10019e137d64353840 Mon Sep 17 00:00:00 2001 From: Corfirean Date: Thu, 22 Oct 2026 07:49:15 +0500 Subject: [PATCH] Record every published Manager version in its own changelog (#137) --- .github/workflows/changelog.yml | 2 +- AGENTS.md | 4 + CHANGELOG.md | 21 +++++ changelog/history.json | 145 ++++++++++++++++++++++++++++++++ tools/sync_history.py | 108 ++++++++++++++++-------- tools/test_sync_history.py | 76 ++++++++++++++++- 6 files changed, 317 insertions(+), 39 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index b8cea6e..ced4a65 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -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] diff --git a/AGENTS.md b/AGENTS.md index b050e50..1fef935 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e71baf..4f8cfc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.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. diff --git a/changelog/history.json b/changelog/history.json index 6483e98..49329b3 100644 --- a/changelog/history.json +++ b/changelog/history.json @@ -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)" + ] } ] } diff --git a/tools/sync_history.py b/tools/sync_history.py index c6afb52..0d49623 100644 --- a/tools/sync_history.py +++ b/tools/sync_history.py @@ -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: diff --git a/tools/test_sync_history.py b/tools/test_sync_history.py index f5ac267..a059882 100644 --- a/tools/test_sync_history.py +++ b/tools/test_sync_history.py @@ -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()