diff --git a/README.md b/README.md index 22c89d5..365032d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # FabledCurator + Self-hosted media curation — a gallery, ML auto-tagging, and subscription-driven downloading in one application. Part of the FabledSword family. @@ -19,13 +20,15 @@ through afterwards. characters, find near-duplicates and power similarity search. Suggestions are reviewable — it proposes, you confirm, and it learns which proposals you keep rejecting. -- **Importing.** Ingests an existing library from disk, deduplicates it by - content hash, and reads metadata sidecars. +- **Deduplication and provenance.** Everything that arrives is hashed and + deduplicated by content, metadata sidecars are read wherever the source + writes them, and every file keeps a record of where it came from. - **Maintenance.** Backups, library audits, thumbnail and embedding backfills, orphan cleanup — all from the UI, all as background jobs you can watch. Everything is configured from the Settings UI and stored in the database. There is no config file to edit beyond a handful of bootstrap environment variables. + ## Before you expose it diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 0511d99..70487ad 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -33,6 +33,32 @@ history. Ancestry is immune to the shape change, and it is also the more honest question: "what is in this that was not in the last one" IS a reachability question. +Ancestry alone is not enough, though, and milestone 328 is where that showed. +The 28 `v26.*` tags are still in the repo — the operator kept them as history +when their releases were deleted — so `--match v*` walks straight back to +`v26.06.04.0` and reports 533 commits. That span is not a changelog: nobody has +run `v26.06.04.0`, its release page no longer exists to compare against, and +the 200 lines that survive truncation are precisely the internal build-out that +milestone 328 exists to stop shipping. So the match is `v[0-9][0-9][0-9][0-9].*` +— rule 148's four-digit-year shape — which is exactly the set of tags that name +a release a reader could have been running. A pre-convention tag is history, +not a predecessor. + +## The first release has no changelog, and should not pretend to + +Once the match is narrowed, the first rule-148 tag reaches no predecessor at +all, and the old fallback — diff against the whole history — is worse than the +problem it replaced. The honest content for a release nobody has a previous +version of is what the thing IS. + +So a release with no reachable predecessor renders the product overview instead +of a commit list. It is read out of README.md between `` +and `` rather than written here, for the same reason the +changelog is derived: two hand-maintained descriptions of one product drift, +and nothing ever catches it. The release page and the repo front page are one +source. Every later release goes back to being a changelog, which is what §5 of +note #3127 says a release is for. + ## Re-runs update, they do not fall through Note #3127 §6.7: a publisher that POSTs and recovers the id from a `409` never @@ -91,18 +117,45 @@ def git_ok(*args: str) -> str | None: def previous_tag(ref: str, tag: str | None) -> str | None: - """The most recent `v*` tag reachable from `ref`, excluding `tag` itself. + """The most recent rule-148 tag reachable from `ref`, excluding `tag` itself. `--exclude` rather than `^` so this is the same call whether or not `ref` is the tag being released — and so it does not blow up on a root commit that has no parent to walk to. + + The glob deliberately does NOT match the old `v26.*` tags. They are kept as + history and their releases are gone, so naming one as the predecessor emits + a span nobody can look up. See the module docstring. """ - args = ["describe", "--tags", "--abbrev=0", "--match", "v*"] + args = ["describe", "--tags", "--abbrev=0", "--match", "v[0-9][0-9][0-9][0-9].*"] if tag: args += ["--exclude", tag] return git_ok(*args, ref) +def product_overview() -> str | None: + """The product description, lifted verbatim from README.md. + + Returns None if the markers are absent or empty — a missing overview is + reported as a note and the release still publishes, on the same reasoning + as cross_checks(): the release is the useful object even when one part of + the derivation could not run. + """ + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + try: + with open(os.path.join(root, "README.md"), encoding="utf-8") as fh: + readme = fh.read() + except OSError: + return None + match = re.search( + r"(.*?)", + readme, re.S, + ) + if not match: + return None + return match.group(1).strip() or None + + def commits(previous: str | None, ref: str) -> list[str]: """The subjects between the previous release and this one. @@ -125,7 +178,10 @@ def truncate(log: list[str]) -> tuple[list[str], str | None]: ) -def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list[str]) -> str: +def render( + tag: str, sha: str, previous: str | None, log: list[str], notes: list[str], + overview: str | None, +) -> str: short = sha[:7] parts = [] @@ -135,6 +191,25 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list # is the failure this whole milestone is about. parts.append("\n".join(f"> **Note:** {n}" for n in notes)) + # No predecessor means nobody reading this has run an earlier one, so the + # release describes the product rather than a diff. The overview is + # README.md's own words — see the module docstring on why it is not + # written here. + if previous is None and overview: + parts.append(overview) + parts.append( + "## Installing\n\n" + "```\ncurl -O https://git.fabledsword.com/bvandeusen/FabledCurator/raw/" + f"tag/{tag}/docker-compose.yml\ncurl -O https://git.fabledsword.com/" + f"bvandeusen/FabledCurator/raw/tag/{tag}/.env.example\n" + "mv .env.example .env # then set SECRET_KEY, DB_PASSWORD\n" + "docker compose -f docker-compose.yml up -d\n```\n\n" + "**Read \"Before you expose it\" in the README first.** FabledCurator " + "has no login, and it stores live platform session cookies for " + "accounts that usually have a payment method attached. Bind it to a " + "network you trust." + ) + parts.append( f"Built from `{short}`. The rollback unit is the immutable `:c-` tag " f"(rule 145) — these three move together:\n\n```\n" @@ -142,7 +217,19 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list + "\n```" ) - heading = f"## Changes since {previous}" if previous else "## Changes" + if previous is None: + # Deliberately NOT a commit list. The alternative is the whole history + # truncated to MAX_COMMITS, which is 200 lines of internal build-out + # presented to someone who has never seen this project. + parts.append( + "---\n\n_First release under rule 148's `vYYYY.MM.DD.HHMM` shape, so " + "there is no predecessor to diff against and no changelog to derive. " + "The description above is README.md's, quoted at publish time. Later " + "releases carry the commits since the previous one._" + ) + return "\n\n".join(parts) + + heading = f"## Changes since {previous}" if log: parts.append(heading + "\n\n" + "\n".join(f"- {line}" for line in log)) else: @@ -152,10 +239,9 @@ def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list "names the same source under a new name._" ) - span = f"{previous}..{tag}" if previous else tag parts.append( - f"---\n\n_Derived at publish time from `git log --no-merges {span}`. " - f"Nothing here is hand-maintained._" + f"---\n\n_Derived at publish time from " + f"`git log --no-merges {previous}..{tag}`. Nothing here is hand-maintained._" ) return "\n\n".join(parts) @@ -289,13 +375,31 @@ def main() -> None: for note in notes: print(f"release: NOTE {note}") - log = commits(previous, ref) - print(f"release: {len(log)} non-merge commits in the span") - log, overflow = truncate(log) - if overflow: - print(f"release: NOTE {overflow}") - notes.append(overflow) - body = render(tag or ref, sha, previous, log, notes) + # A first release renders the overview instead of a changelog, so the + # commit walk is skipped entirely rather than computed and discarded — + # `commits(None, ref)` is the whole history and there is no reason to ask + # for it. + overview = None + log: list[str] = [] + if previous is None: + overview = product_overview() + if overview is None: + note = ( + "No `` block found in README.md, so this " + "first release has no product description. Published anyway; add " + "the markers and re-run the workflow to fill it in." + ) + print(f"release: NOTE {note}") + notes.append(note) + print("release: no rule-148 predecessor — rendering the product overview") + else: + log = commits(previous, ref) + print(f"release: {len(log)} non-merge commits in the span") + log, overflow = truncate(log) + if overflow: + print(f"release: NOTE {overflow}") + notes.append(overflow) + body = render(tag or ref, sha, previous, log, notes, overview) if args.dry_run or not tag: print("--- body ---") diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py index 129be14..34b354c 100644 --- a/tests/test_release_notes.py +++ b/tests/test_release_notes.py @@ -24,12 +24,36 @@ SCRIPT = ROOT / "scripts" / "release_notes.py" def notes(*args: str, cwd: Path | None = None) -> str: + """Run the script the way release.yml does. + + A synthetic repo runs its OWN copy of the script, because the overview is + read relative to `__file__` rather than to the cwd — which is right in + production (release.yml checks out the tag, so the script IS the tagged + tree's copy) and would otherwise make every synthetic repo silently quote + FabledCurator's real README. + """ + root = cwd or ROOT + script = root / "scripts" / "release_notes.py" return subprocess.run( - ["python3", str(SCRIPT), "--dry-run", *args], - capture_output=True, text=True, check=True, cwd=cwd or ROOT, + ["python3", str(script if script.exists() else SCRIPT), "--dry-run", *args], + capture_output=True, text=True, check=True, cwd=root, ).stdout +OVERVIEW_TEXT = "A synthetic product, described once." + + +def install_script(repo: Path, *, overview: bool = True) -> None: + """Give a synthetic repo the script and a README to quote.""" + (repo / "scripts").mkdir(exist_ok=True) + (repo / "scripts" / "release_notes.py").write_text(SCRIPT.read_text()) + (repo / "scripts" / "artifacts.sh").write_text("#!/bin/sh\nexit 1\n") + readme = "# Synthetic\n\n" + if overview: + readme += f"\n{OVERVIEW_TEXT}\n\n" + (repo / "README.md").write_text(readme) + + def body_of(out: str) -> str: assert "--- body ---" in out, f"no body was rendered:\n{out}" return out.split("--- body ---", 1)[1] @@ -56,9 +80,10 @@ def shaped_history(tmp_path: Path) -> Path: repo = tmp_path / "shaped" repo.mkdir() git(repo, "init", "-q", "-b", "main") + install_script(repo) for i, tag in enumerate(("v26.06.04.0", "v2026.08.28.2208", "v2026.08.29.1000")): (repo / "f.txt").write_text(f"{i}\n") - git(repo, "add", "f.txt") + git(repo, "add", "-A") git(repo, "commit", "-q", "-m", f"work landing in {tag}") git(repo, "tag", tag) # One more commit and a merge, so the merge-exclusion test has something to @@ -109,12 +134,57 @@ def test_merges_are_excluded_so_the_list_is_the_work(shaped_history): assert "Merge pull request #999" not in body -def test_the_first_release_still_renders_with_nothing_behind_it(shaped_history): - """No previous tag is reachable from the oldest one. That is a real state, - not an error, and it must not take the release down with it.""" - out = notes("v26.06.04.0", cwd=shaped_history) +def test_a_pre_convention_tag_is_history_not_a_predecessor(shaped_history): + """The defect milestone 328 hit, and the reason the match glob narrowed. + + The 28 `v26.*` tags are kept as history while their releases were deleted. + Ancestry alone happily names `v26.06.04.0` as the predecessor of the first + rule-148 tag — and then the body offers "changes since" a release that no + longer exists, over a span (533 commits in the real repo) that is the + internal build-out this milestone exists to stop publishing. + + Reachable is not the same as comparable. Only a `vYYYY.` tag names a + release a reader could have been running. + """ + out = notes("v2026.08.28.2208", cwd=shaped_history) assert "previous=" in out - assert "## Changes" in body_of(out) + assert "v26.06.04.0" not in body_of(out) + + +def test_the_first_release_describes_the_product_instead_of_diffing(shaped_history): + """No predecessor means nobody reading has run an earlier version, so a + changelog has no referent. The alternative the script used to take — diff + against the whole history, truncated — puts 200 lines of internal build-out + in front of someone meeting the project for the first time.""" + body = body_of(notes("v2026.08.28.2208", cwd=shaped_history)) + assert OVERVIEW_TEXT in body + assert "## Changes" not in body + assert not [ln for ln in body.split("\n") if ln.startswith("- work landing")] + + +def test_the_overview_is_readmes_words_not_a_second_copy(shaped_history): + """Two hand-maintained descriptions of one product drift and nothing + catches it. The release page quotes README.md so there is one source.""" + readme = (shaped_history / "README.md").read_text() + assert OVERVIEW_TEXT in readme + assert OVERVIEW_TEXT in body_of(notes("v2026.08.28.2208", cwd=shaped_history)) + + +def test_a_missing_overview_block_is_reported_and_still_publishes(tmp_path): + """Same reasoning as cross_checks(): the release is the useful object even + when part of the derivation could not run. Say what is missing, publish + anyway — do not leave the operator with a tag and no release.""" + repo = tmp_path / "no-markers" + repo.mkdir() + git(repo, "init", "-q", "-b", "main") + install_script(repo, overview=False) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "first") + git(repo, "tag", "v2026.09.01.1200") + + out = notes("v2026.09.01.1200", cwd=repo) + assert "No `` block found" in out + assert "No `` block found" in body_of(out) def test_a_non_tag_ref_renders_but_refuses_to_claim_it_published(): @@ -135,15 +205,29 @@ def test_the_rollback_refs_name_all_three_images(): assert f"bvandeusen/{image}:c-" in body, f"{image} missing from the rollback refs" -def test_an_unbounded_span_is_truncated_and_says_so(): - """With no reachable previous tag the span is the whole history. Emitting - eleven hundred lines would bury the one line explaining why there are - eleven hundred of them, so the cap is part of the message, not a silent - slice.""" - out = notes("HEAD") - if "previous=" not in out: - pytest.skip("a previous tag is reachable from HEAD in this checkout") +def test_a_long_span_between_two_releases_is_truncated_and_says_so(tmp_path): + """The cap is still reachable, just not by the route it used to be. + + It no longer fires on "no predecessor" — that renders the overview now. + What it still guards is two real releases far enough apart that the list + stops being something anyone reads, which is the ordinary case for a + project that cuts a bookmark twice a year. The cap is part of the message, + not a silent slice. + """ + repo = tmp_path / "long" + repo.mkdir() + git(repo, "init", "-q", "-b", "main") + install_script(repo) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "scaffold") + git(repo, "tag", "v2026.01.01.0000") + for i in range(205): + git(repo, "commit", "-q", "--allow-empty", "-m", f"fix: change {i}") + git(repo, "tag", "v2026.07.01.0000") + + out = notes("v2026.07.01.0000", cwd=repo) + assert "previous=v2026.01.01.0000" in out body = body_of(out) listed = [ln for ln in body.split("\n") if ln.startswith("- ")] - assert len(listed) <= 200 + assert len(listed) == 200 assert "more than a changelog is for" in body