diff --git a/.github/workflows/build-loremaster.yml b/.github/workflows/build-loremaster.yml index 83dad21..337da79 100644 --- a/.github/workflows/build-loremaster.yml +++ b/.github/workflows/build-loremaster.yml @@ -14,6 +14,7 @@ on: - "docs/**" - "UI_Spin_qeynos_LO1.ini" - "README.md" + - "CHANGELOG.md" # Governs the bytes every other path is checked out with, so a change # here can alter what gets packaged without touching a packaged file. - ".gitattributes" @@ -34,6 +35,7 @@ on: - "docs/**" - "UI_Spin_qeynos_LO1.ini" - "README.md" + - "CHANGELOG.md" # Governs the bytes every other path is checked out with, so a change # here can alter what gets packaged without touching a packaged file. - ".gitattributes" @@ -87,6 +89,16 @@ jobs: - name: Verify change classifier run: python tools/ci_change_scope.py --self-test + - name: Verify release notes tooling + run: python tools/release_notes.py --self-test + + - name: Require a changelog entry for the release being published + if: github.event_name == 'workflow_dispatch' && inputs.publish_release + # Checked here rather than at the publish step, which is twenty + # minutes of building away: a release with nothing to say about + # itself should cost seconds to find out. + run: python tools/release_notes.py --version '${{ inputs.release_tag }}' --check + - name: Classify changed paths id: scope shell: pwsh @@ -537,12 +549,16 @@ jobs: # marker has to sit at column zero and so cannot be indented inside # this step. $version = $tag -replace '^v', '' + $changelog = python tools/release_notes.py --version $tag + if ($LASTEXITCODE -ne 0) { throw "no changelog entry for $tag" } $notes = @() if ($prerelease) { $notes += "> **Release candidate.** Install it by hand and run it. The in-app updaters ignore prereleases, so nobody is offered this build and the current release stays where it is." $notes += "" } $notes += @( + $changelog, + "", "## Installing", "", "**Linux** -- download ``Loremaster-$version-x86_64.AppImage``, ``chmod +x`` it, and run it. There is no self-update on Linux, so new builds always come from this page.", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e143758 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +What each release is for. Written in the pull request that makes the change, +so the release itself carries it: `tools/release_notes.py` reads the entry +matching the version being published, and a candidate and the release it is +promoted to resolve to the same entry. + +## 0.4.0 + +First build carrying the upstream work merged since 0.3.4, plus the fork fixes +that came with it. + +### From upstream ([itsspin/spinips](https://github.com/itsspin/spinips)) + +- **Native themes** — Vellum & Ember, and Midnight Frost for Glass, applied + consistently across every window and remembered between sessions. +- **Adventure journal** — loot and encounter history kept in a local database, + so a session's kills and drops survive a restart. +- **Raid context from the log** — instance and difficulty are read from log + evidence instead of being asked for, so kills are credited without a prompt + where the log already says enough. +- **Item intelligence** and a rebuilt gear plan import. +- **Combat frames** — a foreground attack perimeter, a brighter auto-attack + pulse, and tightened player progression bars. +- **Pet illusions restored**, and alert sounds you can point at your own files. +- **SpinTexture** companion project for sharper world textures. + +### Fork-specific + +- **Updates check this fork.** Both updaters previously asked + `itsspin/spinips` what the newest build was — which never carries the Linux + AppImage, and would install upstream's skins over this fork's. They now + follow this repository. +- **Every raid kill awaiting a difficulty is kept.** A single pending slot + discarded the first kill when two raid targets died before you confirmed a + difficulty, which is exactly what happens when a raid clears several at + once. Each kill now keeps its own zone, character and clear time. + +### Removed + +- **Alt+Z instance lockout OCR.** Upstream retired the feature and this fork + follows them; raid context now comes from the log rather than from scanning + the Instance Information window. `Ctrl+Shift+Z` is no longer claimed. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 206dbf0..cf6d038 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -9,6 +9,30 @@ workflow, run manually (Actions → Run workflow). Three inputs matter: | `release_tag` | The tag to create or update. | | `prerelease` | On by default. Publishes a release candidate. | +## Saying what a release is for + +`CHANGELOG.md` holds one entry per version, and the release carries it. Write +the entry in the pull request that makes the change, while you still know why +you made it -- not at release time, when it has to be reconstructed from a +diff. + + ## 0.4.0 + + What this release is for, in prose. + + ### Fork-specific + - ... + +A candidate and the release it is promoted to read the **same** entry: +`0.4.0-rc.1`, `0.4.0-rc.2` and `0.4.0` all resolve to `## 0.4.0`. So the +narrative you write once is carried by every build that ships it, and nothing +has to be re-typed into a release page. + +Publishing a version with no entry fails in the first job, seconds in, rather +than after a twenty-minute build. Beneath the entry each release also gets +install instructions naming the real artifacts, and the list of pull requests +merged since the last full release, generated by GitHub. + ## Release candidates A candidate is published as a GitHub pre-release, which is what keeps it out of diff --git a/tools/release_notes.py b/tools/release_notes.py new file mode 100644 index 0000000..471bf41 --- /dev/null +++ b/tools/release_notes.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Read one version's entry out of CHANGELOG.md. + +The release workflow prepends this to a release's notes, so what a change was +for is written once, in the pull request that makes it, and is then carried by +every build that ships it -- the candidate and the release it is promoted to +both read the same entry, because both resolve to the same base version. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CHANGELOG = REPO / "CHANGELOG.md" +HEADING = re.compile(r"^## +(?P\S+)", re.MULTILINE) + + +def base_version(version: str) -> str: + """Strip a prerelease suffix: 0.4.0-rc.1 and 0.4.0 share one entry.""" + return re.sub(r"[-+].*$", "", str(version).strip().lstrip("v")) + + +def entry(text: str, version: str) -> str: + """The body under ``## ``, or "" when there is no such heading.""" + wanted = base_version(version) + matches = list(HEADING.finditer(text)) + for index, match in enumerate(matches): + if base_version(match.group("version")) != wanted: + continue + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + return text[start:end].strip("\n").strip() + return "" + + +def _self_test() -> int: + sample = "\n".join(( + "# Changelog", "", + "## 0.4.0", "", + "First entry.", "", + "- a bullet", "", + "## 0.3.4", "", + "Older entry.", + )) + assert entry(sample, "0.4.0").startswith("First entry.") + assert "- a bullet" in entry(sample, "0.4.0") + # A candidate reads its own release's entry, which is the whole point. + assert entry(sample, "0.4.0-rc.1") == entry(sample, "0.4.0") + assert entry(sample, "v0.4.0-rc.2") == entry(sample, "0.4.0") + # The next version's entry must not leak into this one. + assert "Older entry." not in entry(sample, "0.4.0") + assert entry(sample, "0.3.4") == "Older entry." + # A missing entry is reported, never guessed at. + assert entry(sample, "9.9.9") == "" + # The last entry in the file runs to the end of it. + assert entry("## 1.0.0\n\nOnly entry.\n", "1.0.0") == "Only entry." + print("release notes extractor: ALL PASS") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="release version or tag, e.g. v0.4.0-rc.1") + parser.add_argument("--check", action="store_true", + help="exit non-zero when the entry is missing, print nothing") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + return _self_test() + if not args.version: + parser.error("--version is required") + if not CHANGELOG.is_file(): + print(f"{CHANGELOG.name} does not exist", file=sys.stderr) + return 1 + found = entry(CHANGELOG.read_text(encoding="utf-8"), args.version) + if not found: + print( + f"CHANGELOG.md has no '## {base_version(args.version)}' entry, so this " + "release would ship without saying what it is for. Add one and run again.", + file=sys.stderr) + return 1 + if not args.check: + print(found) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_quality_gate.py b/tools/release_quality_gate.py index e4d2167..dab96ba 100644 --- a/tools/release_quality_gate.py +++ b/tools/release_quality_gate.py @@ -764,6 +764,10 @@ def run_source_selftests() -> None: "test_*.py", ], ) + run_command( + "release notes extractor --self-test", + [sys.executable, str(REPO / "tools" / "release_notes.py"), "--self-test"], + ) run_command( "SpinUI installer --selftest", [