Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG_ro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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`
Expand Down
95 changes: 95 additions & 0 deletions scripts/stamp_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Turn the `## Unreleased` section into `## v<version>` 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<version>`. 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())
74 changes: 74 additions & 0 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import os
import sys

import pytest

Expand Down Expand Up @@ -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