release: the first release describes the product; it has nothing to diff against
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 1m42s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 1m42s
Step 7 needs a release that reads as "what is FabledCurator and how do I run
it". What the script would actually have published is "changes since
v26.06.04.0" over 533 commits, truncated to 200 — a release page whose first
screen is the internal build-out that milestone 328 exists to stop shipping,
addressed to a reader who has never seen this project.
Two causes, fixed separately.
**A pre-convention tag is history, not a predecessor.** The 28 `v26.*` tags
were kept when their releases were deleted, so `--match v*` walks ancestry
straight back to one of them. Reachable is not comparable: nobody has run
v26.06.04.0 and its release page no longer exists to compare against. The
match is now `v[0-9][0-9][0-9][0-9].*` — rule 148's shape, which is exactly
the set of tags naming a release a reader could have been running.
**With that narrowed, the first rule-148 tag reaches no predecessor**, and the
old fallback — diff against the whole history — is worse than the problem it
replaced. A release with no predecessor now renders the product overview and
no commit list at all.
The overview is READ OUT OF README.md between `<!-- overview:start -->` and
`<!-- overview:end -->`, not written into the script, 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. Missing markers are reported as a note and publish anyway, on
cross_checks()'s reasoning — the release is still the useful object.
Every later release goes back to being a changelog, which is what note #3127
§5 says a release is for. MAX_COMMITS still guards the case it now guards:
two real releases far enough apart that the list stops being readable.
Also corrected while marking up the README: "Importing — ingests an existing
library from disk" was still advertising the folder-import feature that
3590c47 documented as deliberately retired. Replaced with what FC actually
does with what arrives — content-hash dedup, sidecar metadata, provenance.
Tests: the two that asserted the old no-predecessor behaviour are rewritten
rather than left; synthetic repos now carry their own copy of the script,
since the overview resolves relative to `__file__` (correct in production,
where release.yml checks out the tag) and would otherwise have every fixture
silently quoting FabledCurator's real README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# FabledCurator
|
||||
|
||||
<!-- overview:start -->
|
||||
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.
|
||||
<!-- overview:end -->
|
||||
|
||||
## Before you expose it
|
||||
|
||||
|
||||
+118
-14
@@ -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 `<!-- overview:start -->`
|
||||
and `<!-- overview:end -->` 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 `<ref>^` 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"<!--\s*overview:start\s*-->(.*?)<!--\s*overview:end\s*-->",
|
||||
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 `<!-- overview:start -->` 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 ---")
|
||||
|
||||
+101
-17
@@ -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"<!-- overview:start -->\n{OVERVIEW_TEXT}\n<!-- overview:end -->\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=<none>" 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 `<!-- overview:start -->` block found" in out
|
||||
assert "No `<!-- overview:start -->` 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=<none>" 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
|
||||
|
||||
Reference in New Issue
Block a user