diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 619b59f59e..0a90adebd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -275,9 +275,6 @@ jobs: path: appcast/appcast.xml retention-days: 90 - - name: Extract release notes from CHANGELOG.md - run: scripts/ci/extract-release-notes.sh "${GITHUB_REF#refs/tags/v}" - - name: Create GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 42e8b2c681..6d0aa6dac7 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -90,6 +90,9 @@ jobs: - name: Validate the test quarantine script run: python3 scripts/ci/test_quarantine_args.py + - name: Validate release note extraction + run: python3 scripts/ci/test_release_notes.py + # A check that guarded a real invariant and ran nowhere. Pure grep over Swift sources, so it # belongs on the free Linux runner. The MongoDB filter-shape check is the other one that was # orphaned, but it compiles a C probe against Libs/libbson, so it lives in the macOS build diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f020f2a8..ccd2444646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Update release notes show all changes for the offered version, with new features before fixes and properly formatted Markdown. The full changelog is also available from Help and Software Update settings. - Blank welcome window list when a search matched nothing and a favorite existed. - Welcome window reading No Connections while a tag filter hid every connection. - Favorited connection inside a group listed twice on the welcome window. diff --git a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift index c3c2a63b62..6ec7c963a6 100644 --- a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift +++ b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift @@ -101,6 +101,10 @@ extension AppDelegate: NSMenuItemValidation { open(MainMenuLink.documentation) } + @objc func openChangelog(_ sender: Any?) { + open(MainMenuLink.changelog) + } + @objc func openGitHubRepository(_ sender: Any?) { open(MainMenuLink.repository) } @@ -127,6 +131,7 @@ extension AppDelegate: NSMenuItemValidation { } enum MainMenuLink { + static let changelog = "https://docs.tablepro.app/changelog" static let website = "https://tablepro.app" static let documentation = "https://docs.tablepro.app" static let repository = "https://github.com/TableProApp/TablePro" diff --git a/TablePro/Core/Menu/HelpMenuBuilder.swift b/TablePro/Core/Menu/HelpMenuBuilder.swift index 9037cae925..8916a3634d 100644 --- a/TablePro/Core/Menu/HelpMenuBuilder.swift +++ b/TablePro/Core/Menu/HelpMenuBuilder.swift @@ -25,6 +25,10 @@ enum HelpMenuBuilder { String(localized: "GitHub Repository"), action: #selector(AppDelegate.openGitHubRepository(_:)) ), + MenuItemFactory.item( + String(localized: "What's New"), + action: #selector(AppDelegate.openChangelog(_:)) + ), MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Getting Started"), diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index ff795156bb..1cf2db6e5d 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -130,6 +130,10 @@ struct GeneralSettingsView: View { updaterBridge.checkForUpdates() } .disabled(!updaterBridge.canCheckForUpdates) + + if let changelogURL = URL(string: MainMenuLink.changelog) { + Link("What's New", destination: changelogURL) + } } Section { diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 847cf252a4..03d66c10d8 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -29,6 +29,17 @@ private func flatten(_ menu: NSMenu) -> [NSMenuItem] { @Suite("Main menu structure") @MainActor struct MainMenuStructureTests { + @Test("What's New stays reachable from Help without an active connection") + func changelogIsReachable() throws { + let help = try #require(buildMenu().items.first { $0.title == String(localized: "Help") }?.submenu) + let item = try #require(help.items.first { $0.title == String(localized: "What's New") }) + #expect(item.action == #selector(AppDelegate.openChangelog(_:))) + #expect(item.target == nil) + #expect(item.keyEquivalent.isEmpty) + #expect(AppDelegate().validateMenuItem(item)) + #expect(MainMenuLink.changelog == "https://docs.tablepro.app/changelog") + } + @Test("Top level order follows the macOS HIG") func topLevelOrder() { let titles = buildMenu().items.map(\.title) diff --git a/docs/customization/general-settings.mdx b/docs/customization/general-settings.mdx index f1f62268aa..032270ad4a 100644 --- a/docs/customization/general-settings.mdx +++ b/docs/customization/general-settings.mdx @@ -50,6 +50,10 @@ External links you chose to always allow, each with **Forget**, plus **Forget Al ## Software update +The update window shows the complete changelog for the offered version: new features first, then improvements and fixes. Longer notes scroll inside the window; no entries are shortened or omitted. + +**What's New** opens the full changelog, including features and fixes in each release. It is also available from **Help > What's New**, even after dismissing an update notification. + **Automatically check for updates** is on and checks in the background. **Check for Updates…** checks now, here or as **TablePro > Check for Updates…** in the menu bar. Updates come through [Sparkle](https://sparkle-project.org/) and are signature-checked before they install. ## Privacy diff --git a/scripts/ci/extract-release-notes.py b/scripts/ci/extract-release-notes.py new file mode 100644 index 0000000000..72e2d2a897 --- /dev/null +++ b/scripts/ci/extract-release-notes.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Keep a release's complete Markdown, with new features before fixes.""" + +from pathlib import Path +import re +import sys + + +SECTION_ORDER = { + "added": 1, + "features": 1, + "new features": 1, + "changed": 2, + "performance": 2, + "fixed": 3, +} + + +def extract_notes(changelog, version): + # Keep each section intact, including nested lists, code, and unknown headings. + # The sort is stable, so sections at the same priority keep their original order. + sections = [(0, [])] + found = False + fence = None + for line in changelog.splitlines(keepends=True): + if fence is not None: + if re.fullmatch(r" {0,3}" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}\s*", line): + fence = None + if found: + sections[-1][1].append(line) + continue + + opening_fence = re.match(r"^ {0,3}(`{3,}|~{3,})", line) + if opening_fence: + fence = opening_fence[1] + if found: + sections[-1][1].append(line) + continue + + release = re.match(r"^## \[([^]]+)\]", line) + if release: + if found: + break + found = release[1] == version + continue + + if found: + heading = re.match(r"^### (.+?)\s*$", line) + if heading: + sections.append((SECTION_ORDER.get(heading[1].casefold(), 4), [])) + sections[-1][1].append(line) + + blocks = ["".join(lines).strip("\n") for _, lines in sorted(sections, key=lambda section: section[0])] + notes = "\n\n".join(block for block in blocks if block.strip()) + if not notes.strip(): + raise ValueError(f"No release notes found for version {version} in CHANGELOG.md") + return notes + "\n" + + +def main(): + if len(sys.argv) != 2: + sys.exit("Usage: extract-release-notes.py ") + try: + notes = extract_notes(Path("CHANGELOG.md").read_text(encoding="utf-8"), sys.argv[1]) + Path("release_notes.md").write_text(notes, encoding="utf-8") + except (OSError, ValueError) as error: + sys.exit(f"ERROR: {error}") + print(f"Release notes extracted for {sys.argv[1]}:") + print(notes, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/extract-release-notes.sh b/scripts/ci/extract-release-notes.sh index dbf5339f5c..aef4259912 100755 --- a/scripts/ci/extract-release-notes.sh +++ b/scripts/ci/extract-release-notes.sh @@ -3,24 +3,4 @@ set -euo pipefail VERSION="${1:?Usage: extract-release-notes.sh }" -echo "Extracting release notes for version: $VERSION" - -# Extract the section for this version from CHANGELOG.md -# Matches from "## [X.Y.Z]" until the next "## [" or end of file -NOTES=$(awk -v ver="$VERSION" ' - /^## \[/ { - if (found) exit - if ($0 ~ "\\[" ver "\\]") { found=1; next } - } - found { print } -' CHANGELOG.md) - -if [ -z "$NOTES" ]; then - echo "⚠️ No changelog entry found for version $VERSION, using fallback" - echo "- Bug fixes and improvements" > release_notes.md -else - echo "$NOTES" > release_notes.md -fi - -echo "✅ Release notes extracted" -cat release_notes.md +python3 "$(dirname "$0")/extract-release-notes.py" "$VERSION" diff --git a/scripts/ci/sign-and-appcast.sh b/scripts/ci/sign-and-appcast.sh index b9109ba9be..c6bacaa5e3 100755 --- a/scripts/ci/sign-and-appcast.sh +++ b/scripts/ci/sign-and-appcast.sh @@ -20,7 +20,12 @@ if [ -z "${SPARKLE_PRIVATE_KEY:-}" ]; then fi # --------------------------------------------------------------------------- -# 1. Locate Sparkle tools +# 1. Extract the same version-specific notes used by the GitHub release +# --------------------------------------------------------------------------- +bash "$(dirname "$0")/extract-release-notes.sh" "$VERSION" + +# --------------------------------------------------------------------------- +# 2. Locate Sparkle tools # --------------------------------------------------------------------------- # Pinned and checksum-verified rather than installed from a cask that tracks latest. This step # holds the EdDSA private key that signs every update every user receives, so it should not run a @@ -35,35 +40,6 @@ echo "$SPARKLE_SHA256 $SPARKLE_DIR/sparkle.tar.xz" | shasum -a 256 -c - tar xf "$SPARKLE_DIR/sparkle.tar.xz" -C "$SPARKLE_DIR" SPARKLE_BIN="$SPARKLE_DIR/bin" -# --------------------------------------------------------------------------- -# 2. Extract release notes from CHANGELOG.md → HTML -# --------------------------------------------------------------------------- -if [ -f release_notes.md ]; then - NOTES=$(cat release_notes.md) -else - NOTES=$(awk "/^## \\[${VERSION}\\]/{flag=1; next} /^## \\[/{flag=0} flag" CHANGELOG.md) -fi - -if [ -z "$NOTES" ]; then - RELEASE_HTML="" -else - RELEASE_HTML=$(echo "$NOTES" | sed -E \ - -e 's/^### (.+)$/

\1<\/h3>/' \ - -e 's/^- (.+)$/
  • \1<\/li>/' \ - -e '/^[[:space:]]*$/d' \ - | awk ' - /
  • / { - if (!in_list) { print "
      "; in_list=1 } - print; next - } - { - if (in_list) { print "
    "; in_list=0 } - print - } - END { if (in_list) print "" } - ') -fi - DOWNLOAD_PREFIX="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-TableProApp/TablePro}/releases/download/v${VERSION}/" KEY_FILE=$(mktemp) @@ -91,9 +67,14 @@ for arch in "${ARCHS[@]}"; do cp "$ZIP" "$STAGING/" - # Release notes file matching archive name + # Sparkle 2.9 renders Markdown natively, including code and links. Feeding hand-built HTML + # left Markdown visible and interpreted literal SQL/XML angle brackets as HTML tags. basename="${STAGING}/TablePro-${VERSION}-${arch}" - echo "$RELEASE_HTML" > "${basename}.html" + { + printf "# What's New in TablePro %s\n\n" "$VERSION" + cat release_notes.md + printf '\n[View full changelog](https://docs.tablepro.app/changelog)\n' + } > "${basename}.md" # Seed the generator with the feed that is actually published, so every version already in it # survives. The default is the checkout's own appcast.xml, which is the file as of the tag @@ -106,6 +87,7 @@ for arch in "${ARCHS[@]}"; do --ed-key-file "$KEY_FILE" \ --download-url-prefix "$DOWNLOAD_PREFIX" \ --embed-release-notes \ + --full-release-notes-url "https://docs.tablepro.app/changelog" \ --maximum-versions 0 \ "$STAGING" diff --git a/scripts/ci/test_release_notes.py b/scripts/ci/test_release_notes.py new file mode 100644 index 0000000000..77ac70b518 --- /dev/null +++ b/scripts/ci/test_release_notes.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Regression tests for the notes shared by Sparkle and GitHub releases. + +Run: python3 scripts/ci/test_release_notes.py +""" + +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("extract-release-notes.sh").resolve() + + +class ReleaseNotesTests(unittest.TestCase): + def extract(self, changelog, version="1.2.3", stale_notes=None): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work / "CHANGELOG.md").write_text(changelog, encoding="utf-8") + notes = work / "release_notes.md" + if stale_notes is not None: + notes.write_text(stale_notes, encoding="utf-8") + result = subprocess.run( + ["bash", str(SCRIPT), version], + cwd=work, + capture_output=True, + text=True, + timeout=10, + ) + return result, notes.read_text(encoding="utf-8") if notes.exists() else None + + def test_only_requested_version_is_extracted(self): + result, notes = self.extract( + "## [Unreleased]\n- Not released yet\n\n" + "## [1.2.3] - 2026-09-10\n\n### Fixed\n\n- Current fix\n\n" + "## [1.2.2]\n- Old fix\n" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "### Fixed\n\n- Current fix\n") + + def test_version_is_literal_not_a_regular_expression(self): + result, notes = self.extract( + "## [1x2x3]\n- Wrong version\n" + "## [1.2.3-beta.1]\n- Prerelease\n" + "## [1.2.3]\n- Stable\n" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "- Stable\n") + + def test_prerelease_version_at_end_of_file(self): + result, notes = self.extract( + "## [1.2.3-beta.1]\n- Prerelease", version="1.2.3-beta.1" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "- Prerelease\n") + + def test_preserves_markdown_and_literal_code(self): + body = ( + "### Fixed\n\n" + "- `SELECT * FROM users WHERE id < 10` & `` stay visible.\n" + "- **Important**: [Details](https://example.com/fix?a=1&b=2).\n" + "- Phím tắt `Shift+Space`.\n\n" + "```sql\nSELECT '';\n```\n" + ) + result, notes = self.extract("## [1.2.3]\n" + body) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, body) + + def test_missing_or_blank_notes_fail_without_a_generic_fallback(self): + for changelog in ( + "## [1.2.2]\n- Old fix\n", + "## [1.2.3]\n\n## [1.2.2]\n- Old fix\n", + "## [1.2.3]\n \t\n\n", + ): + with self.subTest(changelog=changelog): + result, notes = self.extract(changelog) + self.assertNotEqual(result.returncode, 0) + self.assertIn("No release notes found for version 1.2.3", result.stderr) + self.assertIsNone(notes) + + def test_existing_notes_do_not_override_the_current_changelog(self): + result, notes = self.extract("## [1.2.3]\n- Current fix\n", stale_notes="- Old fix\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "- Current fix\n") + + def test_large_release_is_not_truncated(self): + body = "- A detailed release note with `code` and a fix.\n" * 4000 + result, notes = self.extract("## [1.2.3]\n" + body) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, body) + + def test_features_precede_fixes_without_dropping_other_sections(self): + intro = "All changes in this release." + fixed = "### Fixed\n\n- Fix one\n- Fix two" + security = "### Security\n\n- Security fix" + features = "### Added\n\n- Feature one\n - Nested detail\n- Feature two" + changed = "### Changed\n\n- Improvement" + removed = "### Removed\n\n- Removed behavior" + result, notes = self.extract( + "## [1.2.3]\n\n" + "\n\n".join([intro, fixed, security, features, changed, removed]) + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "\n\n".join([intro, features, changed, fixed, security, removed]) + "\n") + + def test_features_heading_aliases_are_first(self): + for heading in ("Features", "New Features"): + with self.subTest(heading=heading): + result, notes = self.extract(f"## [1.2.3]\n### Fixed\n- Fix\n\n### {heading}\n- Feature\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, f"### {heading}\n- Feature\n\n### Fixed\n- Fix\n") + + def test_headings_inside_code_are_not_release_or_section_boundaries(self): + for fence in ("```", "~~~~"): + with self.subTest(fence=fence): + fixed = f"### Fixed\n\n- Example:\n\n{fence}markdown\n## [1.2.2]\n### Added\n{fence}\n" + result, notes = self.extract("## [1.2.3]\n" + fixed + "\n### Added\n\n- Actual feature\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(notes, "### Added\n\n- Actual feature\n\n" + fixed) + + def test_latest_real_release_keeps_every_change(self): + changelog = (SCRIPT.parents[2] / "CHANGELOG.md").read_text(encoding="utf-8") + releases = list(re.finditer(r"^## \[([^]]+)\].*$", changelog, re.MULTILINE)) + index = next(index for index, release in enumerate(releases) if release[1] != "Unreleased") + release = releases[index] + end = releases[index + 1].start() if index + 1 < len(releases) else len(changelog) + body = changelog[release.end():end] + result, notes = self.extract(changelog, version=release[1]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertCountEqual( + [line for line in notes.splitlines() if line.strip()], + [line for line in body.splitlines() if line.strip()], + ) + if "### Added\n" in notes and "### Fixed\n" in notes: + self.assertLess(notes.index("### Added\n"), notes.index("### Fixed\n")) + + +if __name__ == "__main__": + unittest.main()