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