From e386e0e0014101fbaee4d97ce0d527aefb9ee382 Mon Sep 17 00:00:00 2001 From: ThorSP Date: Tue, 8 Sep 2026 18:35:19 +0300 Subject: [PATCH] fix: stamp release notes at release time, so users actually see them `changelog.py` ignores non-version headings, so anything under `## Unreleased` is invisible to users - and nothing ever moved those notes out. v1.6.1 shipped with both its notes still unreleased and showed its users no release notes at all. Nobody was stamping them by hand, so the release has to. - `scripts/stamp_changelog.py` renames `## Unreleased` to `## v` in both language files. Idempotent (a re-run must not swallow the next version's notes into a released section), refuses a non-x.y.z version, and leaves an empty Unreleased alone so a release with no user-visible change cannot ship a bare heading. Exit 1 means "nothing to stamp", which is a normal outcome. - `release.yml` runs it in `create-release` **before the tag is cut** and commits the result, so the notes leave `## Unreleased` instead of being re-shipped verbatim under the next version too. That commit carries a skip-CI marker, since a push to main would otherwise start a second Release run. - All four build jobs now check out **the tag** rather than the triggering commit - the stamp only exists on the tag, so otherwise they would each build the pre-stamp changelog. They are already gated on a non-empty version, and the tag exists by then on both trigger paths (push+bump, and tag push). Also corrects the history the missing stamp left behind: the icon fix did ship in v1.6.1 and now sits under that heading, while the Made-for-Kids fix stays under Unreleased, since it only actually works once the solver-script fix lands. A user going 1.6.1 -> 1.6.2 then sees exactly the one note that is new to them. Tests cover the rename, idempotency, the empty section, a bad version, and the point of the whole thing - that `notes_since()` returns nothing before stamping and the note afterwards. Idempotency and the empty-section guard were both mutation-checked. Learned the hard way while writing this: the first version of this very commit message quoted the skip-CI token verbatim to explain it, which skipped CI on the PR. CLAUDE.md now warns about it - a squash merge would carry it to main too. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 40 +++++++++++++++ CHANGELOG.md | 2 + CHANGELOG_ro.md | 2 + CLAUDE.md | 3 ++ scripts/stamp_changelog.py | 95 +++++++++++++++++++++++++++++++++++ tests/test_changelog.py | 74 +++++++++++++++++++++++++++ 6 files changed, 216 insertions(+) create mode 100755 scripts/stamp_changelog.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1492ab5..e156f84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,30 @@ jobs: echo "create_tag=$CREATE_TAG" >> $GITHUB_OUTPUT echo "Final Version: $VERSION" + # Notes are written under `## Unreleased` as work lands, and changelog.py + # ignores non-version headings - so without this the release ships with + # every note still unreleased and users see none. v1.6.1 shipped that way. + # Must run BEFORE the tag: the tag is what the build jobs check out. + - name: Stamp the changelog with this version + id: stamp + if: steps.get_version.outputs.create_tag == 'true' && steps.get_version.outputs.version != '' + run: | + VERSION="${{ steps.get_version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Exit 1 means "nothing to stamp" (no Unreleased section, or empty) - + # a normal outcome, not a failure, so don't fail the release for it. + if python3 scripts/stamp_changelog.py "$VERSION"; then + git add CHANGELOG.md CHANGELOG_ro.md + # [skip ci] or this push to main starts a second Release run. + git commit -m "chore: stamp changelog for v$VERSION [skip ci]" + git push origin HEAD:${{ github.event.repository.default_branch }} + echo "Stamped and pushed." + else + echo "Nothing to stamp; leaving the changelog alone." + fi + - name: Create and Push Tag if: steps.get_version.outputs.create_tag == 'true' && steps.get_version.outputs.version != '' run: | @@ -194,6 +218,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # The tag, not the triggering commit: the changelog is stamped in + # create-release, so only the tag carries the shipped release notes. + ref: v${{ needs.create-release.outputs.version }} - name: Set up Python uses: actions/setup-python@v6 @@ -269,6 +297,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # The tag, not the triggering commit: the changelog is stamped in + # create-release, so only the tag carries the shipped release notes. + ref: v${{ needs.create-release.outputs.version }} - name: Set up Python uses: actions/setup-python@v6 @@ -346,6 +378,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # The tag, not the triggering commit: the changelog is stamped in + # create-release, so only the tag carries the shipped release notes. + ref: v${{ needs.create-release.outputs.version }} - name: Set up Python uses: actions/setup-python@v6 @@ -423,6 +459,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # The tag, not the triggering commit: the changelog is stamped in + # create-release, so only the tag carries the shipped release notes. + ref: v${{ needs.create-release.outputs.version }} - name: Set up Python uses: actions/setup-python@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1ab9d..ea2b862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased - **Children's Videos Download Again, in Full Quality**: Videos marked "Made for Kids" on YouTube — children's songs, Bible stories and the like — all failed with "This video is not available", even though they play perfectly in a browser. YouTube holds those back unless the app can run a piece of its code, so the app now carries the small engine needed. They download at full 1080p again, and any link that still refuses is retried a second way before the app gives up + +## v1.6.1 - **Sharper App Icon**: The icon shipped at a single small size, so Windows stretched it for shortcuts, folder views and notifications — it looked fuzzy nearly everywhere. It now includes every size Windows asks for, up to 256px ## v1.6.0 diff --git a/CHANGELOG_ro.md b/CHANGELOG_ro.md index 5cb4fb2..77aeef6 100644 --- a/CHANGELOG_ro.md +++ b/CHANGELOG_ro.md @@ -2,6 +2,8 @@ ## Unreleased - **Videoclipurile pentru copii se descarcă din nou, la calitate maximă**: Videoclipurile marcate „Made for Kids” pe YouTube — cântece pentru copii, istorioare biblice și altele — eșuau toate cu „Acest videoclip nu este disponibil”, deși se redau perfect în browser. YouTube le reține dacă aplicația nu poate rula o bucată din codul lui, așa că aplicația include acum micul motor necesar. Se descarcă din nou la 1080p complet, iar un link care tot refuză este reîncercat pe a doua cale înainte ca aplicația să renunțe + +## v1.6.1 - **Pictogramă mai clară**: Pictograma era livrată într-o singură dimensiune mică, așa că Windows o întindea pentru scurtături, foldere și notificări — arăta neclară aproape peste tot. Acum include toate dimensiunile cerute de Windows, până la 256px ## v1.6.0 diff --git a/CLAUDE.md b/CLAUDE.md index ba0feb8..eb2ffdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,9 @@ The build bundles config defaults from `config/`, platform binaries for mpv/ffmp - **Documentation (`docs/`)**: In-app help (Markdown), with `_ro` variants - `main_help.md`, `settings_help.md` (+ `main_help_ro.md`, `settings_help_ro.md`) - Release notes live at the repo **root**: `CHANGELOG.md` + `CHANGELOG_ro.md`. `release.yml` copies `CHANGELOG*.md` into the dist root, so a new translation ships without touching the workflow. **Keep the two files structurally identical** — same sections, same bullet counts; `tests/test_changelog.py` asserts it. + - **Write new notes under `## Unreleased`; the release stamps them.** Nobody moves them by hand — `scripts/stamp_changelog.py` renames that heading to `## v` in the `create-release` job, **before the tag is cut**, and commits it with a skip-CI marker in the message, or the push to `main` starts a second Release run. + - ⚠ **Never put the literal skip-CI token in a commit message or a PR body.** GitHub honours it anywhere in the message, body included — and a squash merge uses the PR body as the commit message, so it silently skips CI on `main` too. It cost a CI-less PR here. Describe it in prose (as this line does) and keep the real token inside the workflow file. The build jobs therefore check out **the tag**, not the triggering commit, or they would ship the pre-stamp file. If `## Unreleased` is gone, add the heading back when you write the next note; the stamper never re-creates it. + - ⚠ **`changelog.py` ignores non-version headings**, so a note left under `## Unreleased` is invisible to users — no error, it simply never appears. **v1.6.1 shipped that way and showed its users nothing.** The stamper is what prevents it; `tests/test_changelog.py` pins the whole chain, including that `notes_since()` returns nothing before stamping and the note after. - **Tests (`tests/`)**: pytest suite - Core download: `test_downloader.py`, `test_download.py`, `test_video_check.py`, `test_old_video_deletion.py`, `test_edge_cases.py`, `test_multi_channel.py` diff --git a/scripts/stamp_changelog.py b/scripts/stamp_changelog.py new file mode 100755 index 0000000..419e846 --- /dev/null +++ b/scripts/stamp_changelog.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Turn the `## Unreleased` section into `## v` at release time. + +Why this exists: `app/backend/changelog.py` deliberately ignores non-version +headings, so anything sitting under `## Unreleased` is invisible to users. Notes +are written under that heading as work lands, and nothing ever moved them - so +v1.6.1 shipped with every note still unreleased and showed its users no release +notes at all. + +`release.yml` runs this in the `create-release` job *before* the tag is created, +and commits the result, so: + * the tag - and therefore every built artifact - carries the stamped file, and + * the notes leave `## Unreleased`, instead of being re-shipped verbatim under + the next version too. + +Deliberately does NOT re-add an empty `## Unreleased`: whoever writes the next +note adds the heading back. An empty section would only be a thing to explain. + +Both language files are stamped identically, which is what keeps +`tests/test_changelog.py`'s structural-parity assertion true. +""" +from __future__ import annotations + +import argparse +import os +import re +import sys + +UNRELEASED_RE = re.compile(r"^##\s+Unreleased\s*$", re.IGNORECASE | re.MULTILINE) +CHANGELOGS = ("CHANGELOG.md", "CHANGELOG_ro.md") + + +def section_body(content: str, start: int) -> str: + """Text between the heading that starts at `start` and the next `## `.""" + rest = content[start:] + nl = rest.find("\n") + if nl == -1: + return "" + body = rest[nl + 1:] + nxt = re.search(r"^##\s", body, re.MULTILINE) + return body[:nxt.start()] if nxt else body + + +def stamp(content: str, version: str) -> tuple[str, str]: + """Rename `## Unreleased` to `## v`. Returns (new_content, note).""" + heading = f"## v{version}" + if re.search(rf"^{re.escape(heading)}\s*$", content, re.MULTILINE): + return content, f"already has {heading}" + + match = UNRELEASED_RE.search(content) + if not match: + return content, "no Unreleased section" + + # An empty section must not become an empty version section - a release + # with no user-visible change should list nothing, not a bare heading. + if not section_body(content, match.start()).strip(): + return content, "Unreleased section is empty" + + return content[:match.start()] + heading + content[match.end():], f"stamped -> {heading}" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("version", help="version being released, without a leading v") + ap.add_argument("--root", default=".", help="repository root") + ap.add_argument("--check", action="store_true", + help="report what would change, write nothing") + args = ap.parse_args(argv) + + version = args.version.lstrip("vV") + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + print(f"error: {args.version!r} is not a x.y.z version", file=sys.stderr) + return 2 + + changed = False + for name in CHANGELOGS: + path = os.path.join(args.root, name) + if not os.path.exists(path): + print(f"{name}: missing, skipped") + continue + with open(path, encoding="utf-8") as f: + content = f.read() + new, note = stamp(content, version) + print(f"{name}: {note}") + if new != content: + changed = True + if not args.check: + with open(path, "w", encoding="utf-8") as f: + f.write(new) + # Exit 1 with nothing to do, so the workflow can skip an empty commit. + return 0 if changed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 6be2a95..b2bfb27 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -5,6 +5,7 @@ """ import os +import sys import pytest @@ -187,3 +188,76 @@ def bullets(section): for a, b in zip(en, ro): assert bullets(a) == bullets(b), f"bullet count differs for {a['version']}" + + +# --- stamping a release --------------------------------------------------- +# +# Notes are written under `## Unreleased`, and this module deliberately ignores +# non-version headings - so a release that never stamps them shows its users no +# notes at all. That is what v1.6.1 did. `scripts/stamp_changelog.py` runs in +# release.yml before the tag is cut. + +import subprocess + +STAMP = os.path.join(os.path.dirname(__file__), "..", "scripts", "stamp_changelog.py") + + +def _write_pair(tmp_path, en, ro): + (tmp_path / "CHANGELOG.md").write_text(en, encoding="utf-8") + (tmp_path / "CHANGELOG_ro.md").write_text(ro, encoding="utf-8") + + +def _run(tmp_path, *args): + return subprocess.run([sys.executable, STAMP, *args, "--root", str(tmp_path)], + capture_output=True, text=True) + + +def test_stamp_renames_unreleased_to_the_version(tmp_path): + body = "# Changelog\n\n## Unreleased\n- did a thing\n\n## v1.0.0\n- first\n" + _write_pair(tmp_path, body, body) + r = _run(tmp_path, "1.6.2") + assert r.returncode == 0, r.stderr + for name in ("CHANGELOG.md", "CHANGELOG_ro.md"): + out = (tmp_path / name).read_text(encoding="utf-8") + assert "## v1.6.2\n- did a thing" in out + assert "Unreleased" not in out + # Everything else is untouched. + assert "## v1.0.0\n- first" in out + + +def test_stamp_is_idempotent(tmp_path): + """A re-run must not stamp a second time - it would swallow the next + version's notes into an already-released section.""" + body = "# Changelog\n\n## Unreleased\n- new\n\n## v1.6.2\n- old\n" + _write_pair(tmp_path, body, body) + r = _run(tmp_path, "1.6.2") + assert r.returncode == 1, "nothing should have changed" + assert "already has" in r.stdout + assert (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") == body + + +def test_stamp_leaves_an_empty_unreleased_alone(tmp_path): + """A release with no user-visible change must not get a bare heading.""" + body = "# Changelog\n\n## Unreleased\n\n## v1.0.0\n- first\n" + _write_pair(tmp_path, body, body) + r = _run(tmp_path, "1.6.2") + assert r.returncode == 1 + assert "empty" in r.stdout + assert (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") == body + + +def test_stamp_rejects_a_bad_version(tmp_path): + _write_pair(tmp_path, "## Unreleased\n- x\n", "## Unreleased\n- x\n") + assert _run(tmp_path, "not-a-version").returncode == 2 + + +def test_stamped_notes_become_visible_to_users(tmp_path): + """The whole point: before stamping, notes_since() returns nothing.""" + body = "# Changelog\n\n## Unreleased\n- the fix\n\n## v1.6.1\n- older\n" + _write_pair(tmp_path, body, body) + before = cl.notes_since(body, "1.6.1", "1.6.2") + assert "the fix" not in (before or ""), "unreleased notes must be invisible" + _run(tmp_path, "1.6.2") + after = cl.notes_since((tmp_path / "CHANGELOG.md").read_text(encoding="utf-8"), + "1.6.1", "1.6.2") + assert "the fix" in after