feat: changelog entries stay compact - one paragraph per topic, a size budget in docs verify and version release, version bump names required migration documents (#184)
CI / verify (push) Successful in 6m0s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 33s

Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-build/SKILL.md
- instructions/dev/version-parts.md
- instructions/migrate-corpus.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-06 16:48:02 +02:00
1 parent 2ddcd6be19
commit 044943ae51
12 files changed
+718 -170

No files matched your search

+20 -8
View File
@@ -2504,6 +2504,8 @@ Check the docs that mirror the code.
- 0 success
- 1 A command, contract, or type-form mismatch
- 1 `VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing entry lacks its breaking line, its migration, or the migration document's path
- 1 The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` sections has more than one paragraph or a paragraph over 1 000 bytes
- 1 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale
- 1 A type-spec's own frontmatter fails its schema
- 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter
@@ -2517,6 +2519,8 @@ Check the docs that mirror the code.
**ON FAILURE**
- A command, contract, or type-form mismatch -> Fix the documentation it names, then re-run
- `VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing entry lacks its breaking line, its migration, or the migration document's path -> Run `version bump`, which writes all of them, or fix whichever is wrong
- The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` sections has more than one paragraph or a paragraph over 1 000 bytes -> Shorten or merge the named sections - a smaller change keeps only its bump title - then re-run
- The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale -> Run `docs contract --apply`, then re-run
- A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new
- A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block
@@ -2543,6 +2547,8 @@ Check the docs that mirror the code.
- Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check.
- Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference.
- `INSTALL.md` carries one generated `<!-- wikitool:prerequisites -->` region per platform value of `tools/prerequisites.txt` (`prerequisites-<platform>` for a platform-specific one), each current; and the `<!-- setup-question: <key> -->` markers in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a question the agent asks is never one the human guide leaves out, nor the reverse.
- `VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that crosses the compatibility boundary from the last release carries `**Breaking Change:**`, has a migration document or `**Migration:** none required`, and its `**Migration:**` line names every required migration document targeting it.
- The newest versioned `CHANGES.md` entry is at most 32 000 bytes (UTF-8) as `version notes` prints it, and each of its `###` sections is exactly one paragraph of at most 1 000 bytes, heading line excluded. Older entries and a changelog with no versioned entry are not checked.
- Read-only.
**SEE ALSO**
@@ -3145,7 +3151,8 @@ Raise or continue the one running candidate between two releases.
- 1 `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions
- 1 An escalation to a boundary crossing without `--breaking`, or with neither a migration document targeting the new base nor `--no-migration`
- 1 `--breaking` or `--no-migration` on a bump that crosses nothing
- 1 `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base
- 1 `--no-migration` while a required migration document targets the new base
- 1 `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to replace, or without a required migration document already targeting the new base
**ON FAILURE**
@@ -3153,12 +3160,13 @@ Raise or continue the one running candidate between two releases.
- `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions -> Nothing was written - fix whichever is wrong, then retry
- An escalation to a boundary crossing without `--breaking`, or with neither a migration document targeting the new base nor `--no-migration` -> Nothing was written - add what the error asks for, or, if nothing actually breaks, choose a smaller part
- `--breaking` or `--no-migration` on a bump that crosses nothing -> Nothing was written - drop the flag and retry
- `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base -> Nothing was written - write the migration document or fix the combination, then retry
- `--no-migration` while a required migration document targets the new base -> Nothing was written - drop `--no-migration`, or remove the document if it is wrong, then retry
- `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to replace, or without a required migration document already targeting the new base -> Nothing was written - write the migration document or fix the combination, then retry
**NEVER**
- Never re-run after an uncertain outcome without first reading `VERSION` and the top of `CHANGES.md` - a second run escalates or continues the candidate again.
- Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump list, breaking and migration lines); `--migration-required` is the only way to take the migration line back.
- Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump list, breaking and migration lines); `--migration-required` is the only way to take a `none required` line back.
**NOTES**
@@ -3166,11 +3174,12 @@ Raise or continue the one running candidate between two releases.
- `--major`/`--minor`/`--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down.
- The first bump of a candidate opens its `CHANGES.md` entry - heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact` (default `medium`). Every later bump of the same candidate updates that entry in place: one entry per candidate, not one per bump.
- The bump list renders grouped under `**High/Medium/Low impact**` headings, empty groups omitted - except while every bump is `medium`, where it stays one flat list. `version regrade` corrects a grade after the fact.
- Writes the heading, the bump list and the breaking/migration lines; the entry's prose is left to the author.
- Writes the heading, the bump list and the breaking/migration lines; the entry's prose - a summary and one-paragraph `###` sections, one per topic - is left to the author.
- Whenever a required migration document (`migrates_to:` equal to the candidate's base) exists, every bump writes or refreshes `**Migration:** required - <paths>`, the documents' paths sorted and comma-separated, in place of any `none required` line. The two forms never stand side by side.
- Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question.
- The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`. Both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all.
- On a later crossing of the same candidate, a further `--breaking` **joins** the reasons already recorded (flat on the marker line while there is one, bullets under a bare marker from the second on; repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single migration line.
- `--migration-required` retracts the running candidate's `--no-migration` line; it needs a migration document already targeting the new base. Nothing retracts a recorded `--breaking` reason.
- `--migration-required` replaces the running candidate's `--no-migration` line with the `required` line; it needs a required migration document already targeting the new base. Nothing retracts a recorded `--breaking` reason.
- Enforces that a crossing documents itself, never that the part was chosen correctly.
- Not idempotent: every successful run escalates or continues the candidate again.
- `--dry-run` reports the step without writing.
@@ -3256,13 +3265,15 @@ Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHA
- 0 success
- 1 `VERSION` is already a release - there is no running candidate to fix
- 1 `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions
- 1 Two or more bumps and no summary paragraph above the changesets
- 1 Two or more bumps and no summary paragraph above the `###` sections
- 1 The entry is over 32 000 bytes, or a `###` section has more than one paragraph or a paragraph over 1 000 bytes - the error names each with its size
**ON FAILURE**
- `VERSION` is already a release - there is no running candidate to fix -> After an uncertain run this means it already ran; otherwise there is nothing to release
- `VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest entry name different versions -> Fix whichever is wrong, then retry
- Two or more bumps and no summary paragraph above the changesets -> Write a short summary paragraph right below the bump list, then retry
- Two or more bumps and no summary paragraph above the `###` sections -> Write a short summary paragraph right below the bump list, then retry
- The entry is over 32 000 bytes, or a `###` section has more than one paragraph or a paragraph over 1 000 bytes - the error names each with its size -> Nothing was written - shorten or merge the named sections (a smaller change keeps only its bump title), then retry
**NEVER**
@@ -3273,7 +3284,8 @@ Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHA
- Strips `VERSION`'s `-beta.N` suffix - the candidate's base becomes the release - and closes the candidate's `CHANGES.md` entry.
- Without `--title` the heading keeps whichever bump last set it; `--title` replaces it - the normal case for a candidate that collected several bumps, whose entry wants a summarising heading rather than the most recent one.
- Leaves the entry's machine-managed bump list untouched, as the record of what happened.
- From two bumps on, requires a summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading. A candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too.
- From two bumps on, requires a summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `###` section heading. A candidate with exactly one bump is exempt, since there its own paragraph already is the summary. `--dry-run` runs this check too.
- Refuses an entry that would exceed its budget once released: at most 32 000 bytes (UTF-8) as `version notes` prints it, and every `###` section exactly one paragraph of at most 1 000 bytes, heading line excluded. The same check as `docs verify`, run on the released heading; `--dry-run` runs it too.
- Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to.
- Not idempotent: a second run fails once the suffix is gone.
+79 -1
View File
@@ -27,7 +27,9 @@ start committing derived output.
A fifth has the same shape as the fourth: `VERSION` is not documentation
either, but it is the one number a release stamps into every distributed
instance, and a version raised without a changelog entry ships release notes
that describe the previous release.
that describe the previous release. The same entry is also held to a size
budget, because it becomes the release body, and a body that grew with every
bump once failed to publish at all.
A sixth checks a *reference* rather than a copy: no document `dist export`
ships may cite an issue number, because the board those numbers live on
@@ -1059,6 +1061,61 @@ def check_migration_for_boundary() -> list[str]:
]
def check_migration_line_names_documents() -> list[str]:
"""A crossing entry's `**Migration:**` line names every required migration
document that targets it.
`check_migration_for_boundary` is satisfied by a document's bare
existence, so a release could ship with notes that never point at the
procedure an operator has to run. `version bump` writes the pointer; this
holds an entry to it. Same scope as the two boundary checks beside it: the
newest entry, against the last release.
"""
from chemenu import kb_state
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
version_path = config.ROOT / version_mod.VERSION_FILENAME
if not changes_path.is_file() or not version_path.is_file():
return [] # already reported by check_version_changelog
text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text)
previous = version_mod.last_release(text)
if current is None or previous is None or current.compat_key == previous.compat_key:
return []
named = set(version_mod.migration_paths(version_mod.changes_section(text, current) or ""))
missing = sorted(
m.relative_path
for m in kb_state.load_migrations()
if m.target == current.base and m.is_required and m.relative_path not in named
)
if not missing:
return []
return [
f"{current} crosses the compatibility boundary from {previous}, and "
f"{', '.join(missing)} targets it as a required migration - but its "
f"{version_mod.CHANGES_FILENAME} entry's `{version_mod.MIGRATION_MARKER}` line does not "
"name it. Run `version bump` again (it writes the line), or remove the document if it "
"does not belong to this release"
]
def check_changelog_budget() -> list[str]:
"""The topmost versioned `CHANGES.md` entry stays inside its size budget.
The entry becomes the release body, and the 8.0.0 one failed there at
129 KB. The rule and its numbers live in `version_mod.budget_issues`, which
`version release` also calls before it writes; this runs it in every
session and in CI, long before a release is cut. Older entries are not
checked - their release notes are already published.
"""
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
if not changes_path.is_file():
return [] # already reported by check_version_changelog
return version_mod.budget_issues(changes_path.read_text(encoding="utf-8"))
def check_breaking_change_for_boundary() -> list[str]:
"""A version that crosses the compatibility boundary must say what breaks.
@@ -1146,6 +1203,14 @@ def check_breaking_change_for_boundary() -> list[str]:
"platform-specific one), each current; and the `<!-- setup-question: <key> -->` markers "
"in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a "
"question the agent asks is never one the human guide leaves out, nor the reverse.",
"`VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that "
"crosses the compatibility boundary from the last release carries `**Breaking "
"Change:**`, has a migration document or `**Migration:** none required`, and its "
"`**Migration:**` line names every required migration document targeting it.",
"The newest versioned `CHANGES.md` entry is at most 32 000 bytes (UTF-8) as `version "
"notes` prints it, and each of its `###` sections is exactly one paragraph of at most "
"1 000 bytes, heading line excluded. Older entries and a changelog with no versioned "
"entry are not checked.",
"Read-only.",
),
failures=(
@@ -1153,6 +1218,17 @@ def check_breaking_change_for_boundary() -> list[str]:
cause="A command, contract, or type-form mismatch",
reaction="Fix the documentation it names, then re-run",
),
cli_contract.Failure(
cause="`VERSION` and the newest `CHANGES.md` entry disagree, or a boundary-crossing "
"entry lacks its breaking line, its migration, or the migration document's path",
reaction="Run `version bump`, which writes all of them, or fix whichever is wrong",
),
cli_contract.Failure(
cause="The newest `CHANGES.md` entry is over 32 000 bytes, or one of its `###` "
"sections has more than one paragraph or a paragraph over 1 000 bytes",
reaction="Shorten or merge the named sections - a smaller change keeps only its bump "
"title - then re-run",
),
cli_contract.Failure(
cause="The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale",
reaction="Run `docs contract --apply`, then re-run",
@@ -1228,7 +1304,9 @@ def verify():
+ check_ignored_content()
+ check_version_changelog()
+ check_migration_for_boundary()
+ check_migration_line_names_documents()
+ check_breaking_change_for_boundary()
+ check_changelog_budget()
+ check_no_issue_references()
+ check_toc_regions()
+ check_reference_targets()
+70 -32
View File
@@ -474,7 +474,11 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
"groups omitted - except while every bump is `medium`, where it stays one flat list. "
"`version regrade` corrects a grade after the fact.",
"Writes the heading, the bump list and the breaking/migration lines; the entry's prose "
"is left to the author.",
"- a summary and one-paragraph `###` sections, one per topic - is left to the author.",
"Whenever a required migration document (`migrates_to:` equal to the candidate's base) "
"exists, every bump writes or refreshes `**Migration:** required - <paths>`, the "
"documents' paths sorted and comma-separated, in place of any `none required` line. "
"The two forms never stand side by side.",
"Compatibility follows the **leftmost non-zero component** of the candidate's base, "
"which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a "
"compatible capability, MAJOR a version that is **not a drop-in replacement** - any "
@@ -489,9 +493,9 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
"reasons already recorded (flat on the marker line while there is one, bullets under a "
"bare marker from the second on; repeating a reason verbatim is a no-op), while a "
"further `--no-migration` **replaces** the single migration line.",
"`--migration-required` retracts the running candidate's `--no-migration` line; it "
"needs a migration document already targeting the new base. Nothing retracts a "
"recorded `--breaking` reason.",
"`--migration-required` replaces the running candidate's `--no-migration` line with "
"the `required` line; it needs a required migration document already targeting the new "
"base. Nothing retracts a recorded `--breaking` reason.",
"Enforces that a crossing documents itself, never that the part was chosen correctly.",
"Not idempotent: every successful run escalates or continues the candidate again.",
"`--dry-run` reports the step without writing.",
@@ -517,9 +521,14 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
cause="`--breaking` or `--no-migration` on a bump that crosses nothing",
reaction="Nothing was written - drop the flag and retry",
),
cli_contract.Failure(
cause="`--no-migration` while a required migration document targets the new base",
reaction="Nothing was written - drop `--no-migration`, or remove the document if it "
"is wrong, then retry",
),
cli_contract.Failure(
cause="`--migration-required` combined with `--no-migration`, on a bump with no "
"running candidate, with no `--no-migration` line to retract, or without a "
"running candidate, with no `--no-migration` line to replace, or without a required "
"migration document already targeting the new base",
reaction="Nothing was written - write the migration document or fix the "
"combination, then retry",
@@ -537,7 +546,7 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
"of `CHANGES.md` - a second run escalates or continues the candidate again.",
"Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump "
"list, breaking and migration lines); `--migration-required` is the only way to take "
"the migration line back.",
"a `none required` line back.",
),
see_also=(
"`wikitool version regrade` - corrects an `--impact` grade",
@@ -602,11 +611,13 @@ def bump_command(
`--no-migration` **replaces**: whether content has to change is one
question about the candidate as a whole, not one per crossing.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
migration document first, then re-run with this flag instead of
`--no-migration`. There is no other way to take the line back: it is
machine-written, and invariant 1 forbids hand-editing it."""
Wherever a required migration document targets the candidate's base,
every bump writes `**Migration:** required - <paths>` - the pointer the
release notes give an operator - and a `none required` line cannot stand
beside it. A later bump that finds out `--no-migration` was wrong after
all states so with `--migration-required`: write the migration document
first, then re-run with this flag instead of `--no-migration`. Both lines
are machine-written, and invariant 1 forbids hand-editing them."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch")
@@ -669,10 +680,15 @@ def bump_command(
)
return
if crossing and not was_already_crossing and not no_migration:
from chemenu import kb_state
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
migrations = kb_state.load_migrations()
required_documents = sorted(
m.relative_path for m in migrations if m.target == new_version.base and m.is_required
)
if crossing and not was_already_crossing and not no_migration:
if not any(m.target == new_version.base for m in migrations):
fail(
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
f"instance must migrate - but no migration document targets {new_version.base}.\n"
@@ -687,6 +703,13 @@ def bump_command(
f"{current} -> {new_version} does not."
)
return
if no_migration and required_documents:
fail(
f"--no-migration says no content has to change, but {', '.join(required_documents)} "
f"targets {new_version.base} as a required migration - drop --no-migration (the bump "
"then names the document), or remove the document if it is wrong."
)
return
if migration_required and no_migration:
fail("--migration-required and --no-migration contradict each other on the same bump.")
@@ -705,12 +728,10 @@ def bump_command(
f"`{version_mod.MIGRATION_NONE_MARKER}` line to retract - nothing to do."
)
return
from chemenu import kb_state
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
if not required_documents:
fail(
f"--migration-required retracts the no-migration line, so a migration document must "
f"target {new_version.base} first - write one under "
f"--migration-required replaces the no-migration line with one naming the migration "
f"documents, so a required one must target {new_version.base} first - write one under "
f"{rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
f"(see instructions/migrate-corpus.md), then re-run with --migration-required."
)
@@ -726,7 +747,7 @@ def bump_command(
text, new_version, today_iso(), title.strip(), author,
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
migration_documents=required_documents,
impact=chosen_impact,
),
encoding="utf-8", newline="\n",
@@ -759,9 +780,13 @@ def bump_command(
"Leaves the entry's machine-managed bump list untouched, as the record of what "
"happened.",
"From two bumps on, requires a summary paragraph (at least 200 non-whitespace "
"characters) between the bump list and the first `### <bump title>` changeset "
"heading. A candidate with exactly one bump is exempt, since there its own changeset "
"already is the summary. `--dry-run` runs this check too.",
"characters) between the bump list and the first `###` section heading. A candidate "
"with exactly one bump is exempt, since there its own paragraph already is the "
"summary. `--dry-run` runs this check too.",
"Refuses an entry that would exceed its budget once released: at most 32 000 bytes "
"(UTF-8) as `version notes` prints it, and every `###` section exactly one paragraph "
"of at most 1 000 bytes, heading line excluded. The same check as `docs verify`, run "
"on the released heading; `--dry-run` runs it too.",
"Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto "
"`main`, which `release.yml` reacts to.",
"Not idempotent: a second run fails once the suffix is gone.",
@@ -778,9 +803,15 @@ def bump_command(
reaction="Fix whichever is wrong, then retry",
),
cli_contract.Failure(
cause="Two or more bumps and no summary paragraph above the changesets",
cause="Two or more bumps and no summary paragraph above the `###` sections",
reaction="Write a short summary paragraph right below the bump list, then retry",
),
cli_contract.Failure(
cause="The entry is over 32 000 bytes, or a `###` section has more than one "
"paragraph or a paragraph over 1 000 bytes - the error names each with its size",
reaction="Nothing was written - shorten or merge the named sections (a smaller change "
"keeps only its bump title), then retry",
),
),
examples=(
"tools/wikitool version release --dry-run",
@@ -819,10 +850,12 @@ def release_command(
Refuses when `VERSION` is already a release: there is no running candidate
to fix. Also refuses - Gitea #95 - when the candidate collected two or
more bumps and its entry still has no summary paragraph above the
individual changesets: a release note that is only a chronological bump
list is exactly the thing this refusal exists to stop shipping. A
candidate with exactly one bump is exempt - there, the bump's own
changeset already is the summary."""
`###` sections: a release note that is only a chronological bump list is
exactly the thing this refusal exists to stop shipping. A candidate with
exactly one bump is exempt - there, its own paragraph already is the
summary. And refuses - Gitea #184 - an entry over its size budget
(`version_mod.budget_issues`), checked on the text as released, so the
release body `release.yml` posts is the text that passed."""
try:
current = version_mod.read_version()
except VersionError as exc:
@@ -865,16 +898,21 @@ def release_command(
return
new_version = current.base
released = version_mod.release_entry(text, today_iso(), title.strip() if title else None)
over_budget = version_mod.budget_issues(released)
if over_budget:
fail(
"The released entry would exceed its size budget - nothing was written:\n"
+ "\n".join(f"- {issue}" for issue in over_budget)
)
return
if dry_run:
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
return
version_mod.write_version(new_version)
changes.write_text(
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
encoding="utf-8", newline="\n",
)
changes.write_text(released, encoding="utf-8", newline="\n")
success(
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
+124
View File
@@ -623,6 +623,130 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
assert docs_verify.check_breaking_change_for_boundary() == []
def _required_migration(root, target: str = "2.0.0", obligation: str = "required") -> str:
path = root / "instructions" / "migrations" / f"{target}-retype.md"
path.write_text(
f"---\ntype: types/instruction.md\nname: {target}-retype\ndescription: Retype.\n"
f"manual: true\nmigrates_to: {target}\nobligation: {obligation}\n---\n",
encoding="utf-8",
)
return f"instructions/migrations/{target}-retype.md"
def test_this_repos_migration_line_names_its_documents():
assert docs_verify.check_migration_line_names_documents() == []
def test_a_crossing_entry_that_does_not_name_its_migration_document_is_reported(
tmp_path, monkeypatch
):
"""The document's bare existence satisfies `check_migration_for_boundary`;
the release notes still have to point an operator at it."""
from chemenu import version as version_mod
root = _boundary_tree(
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
)
document = _required_migration(root)
assert docs_verify.check_migration_for_boundary() == []
issues = docs_verify.check_migration_line_names_documents()
assert any(document in issue and "2.0.0-beta.1" in issue for issue in issues)
def test_a_crossing_entry_naming_its_migration_document_passes(tmp_path, monkeypatch):
root = _boundary_tree(
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
marker="**Migration:** required - instructions/migrations/2.0.0-retype.md\n\n",
)
_required_migration(root)
assert docs_verify.check_migration_line_names_documents() == []
def test_an_offered_migration_document_need_not_be_named(tmp_path, monkeypatch):
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
_required_migration(root, obligation="offered")
assert docs_verify.check_migration_line_names_documents() == []
def test_a_compatible_entry_is_not_held_to_naming_documents(tmp_path, monkeypatch):
root = _boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
_required_migration(root, target="1.5.0")
assert docs_verify.check_migration_line_names_documents() == []
# --- the changelog budget (Gitea #184) --------------------------------------
def _budget_tree(tmp_path, monkeypatch, sections: str, older: str = "Body.\n"):
"""A changelog whose topmost entry carries `sections` below its summary,
above an older entry carrying `older`."""
_versioned_tree(
tmp_path, monkeypatch, "2.0.0\n",
"# Changelog\n\nPreamble.\n\n---\n\n"
f"## 2.0.0 - 2026-10-06 - New\n\n**Author:** Someone\n\nSummary.\n{sections}\n---\n\n"
f"## 1.0.0 - 2026-09-01 - Old\n\n**Author:** Someone\n\n{older}",
)
def test_this_repos_newest_changelog_entry_is_inside_its_budget():
"""The 8.0.0 entry, condensed by hand to 22 KB in 15 one-paragraph
sections (largest 895 bytes), is the reference shape - it must pass."""
assert docs_verify.check_changelog_budget() == []
def test_an_entry_over_32000_bytes_is_reported(tmp_path, monkeypatch):
sections = "".join(f"\n### Topic {n}\n\n{'x' * 900}\n" for n in range(40))
_budget_tree(tmp_path, monkeypatch, sections)
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "2.0.0" in issues[0] and "32000" in issues[0]
def test_a_section_with_two_paragraphs_is_reported(tmp_path, monkeypatch):
_budget_tree(tmp_path, monkeypatch, "\n### Two\n\nFirst.\n\nSecond.\n")
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "### Two" in issues[0] and "2 paragraphs" in issues[0]
def test_a_paragraph_over_1000_bytes_is_reported_in_bytes_not_characters(tmp_path, monkeypatch):
"""501 characters, 1002 bytes: the limit counts what Gitea's column counts."""
_budget_tree(tmp_path, monkeypatch, f"\n### Long\n\n{'ä' * 501}\n")
issues = docs_verify.check_changelog_budget()
assert len(issues) == 1
assert "### Long" in issues[0] and "1002 bytes" in issues[0]
def test_a_paragraph_of_exactly_1000_bytes_passes_and_its_heading_is_not_counted(
tmp_path, monkeypatch
):
long_heading = "A heading that is itself quite long " * 3
_budget_tree(
tmp_path, monkeypatch,
f"\n### {long_heading}\n\n\n{'ä' * 500}\n\n\n### Wrapped\n\nOne paragraph\non two lines.\n",
)
assert docs_verify.check_changelog_budget() == []
def test_an_older_entry_over_both_limits_is_not_reported(tmp_path, monkeypatch):
older = "".join(f"### Old {n}\n\n{'z' * 1500}\n\nSecond paragraph.\n\n" for n in range(30))
_budget_tree(tmp_path, monkeypatch, "\n### Fine\n\nShort.\n", older=older)
assert docs_verify.check_changelog_budget() == []
def test_a_changelog_with_no_versioned_entry_has_no_budget_to_check(tmp_path, monkeypatch):
"""A distributed instance's stub carries no versioned entry at all."""
_versioned_tree(tmp_path, monkeypatch, "8.0.0\n", "# Changelog\n\nStub.\n")
assert docs_verify.check_changelog_budget() == []
def test_verify_raises_when_the_changelog_is_over_budget(monkeypatch):
monkeypatch.setattr(docs_verify, "check_changelog_budget", lambda: ["too long"])
with pytest.raises(typer.Exit):
docs_verify.verify()
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
monkeypatch.setattr(
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
+170 -2
View File
@@ -654,6 +654,64 @@ def test_release_passes_with_a_single_bump_and_no_summary(tree):
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
# --- version release: the size budget (Gitea #184) --------------------------
def _append_sections(tree: Path, *bodies: str) -> None:
"""Append `### Topic N` sections to the open candidate's entry, the way
an author writes them below the summary."""
path = tree / "CHANGES.md"
text = path.read_text(encoding="utf-8")
top_start = text.index("## 1.1.0-beta.")
next_entry = text.index("\n---\n", top_start)
sections = "".join(f"\n### Topic {n}\n\n{body}\n" for n, body in enumerate(bodies, start=1))
path.write_text(text[:next_entry] + sections + text[next_entry:], encoding="utf-8")
@pytest.mark.parametrize(
"bodies",
[
("x" * 900,) * 40, # every paragraph fits, the entry does not
("First paragraph.\n\nSecond paragraph.",),
("y" * 1001,),
],
ids=["entry-over-32000", "two-paragraphs", "paragraph-over-1000"],
)
def test_release_refuses_an_entry_over_budget_and_writes_nothing(tree, bodies):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, *bodies)
version_before = (tree / "VERSION").read_bytes()
changes_before = (tree / "CHANGES.md").read_bytes()
with pytest.raises(typer.Exit) as excinfo:
version_cmd.release_command(title=None, dry_run=False)
assert excinfo.value.exit_code == 1
assert (tree / "VERSION").read_bytes() == version_before
assert (tree / "CHANGES.md").read_bytes() == changes_before
def test_release_dry_run_also_refuses_an_entry_over_budget(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, "y" * 1001)
with pytest.raises(typer.Exit):
version_cmd.release_command(title=None, dry_run=True)
def test_release_passes_an_entry_inside_its_budget(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Only bump",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
_append_sections(tree, "y" * 1000, "One paragraph,\nwrapped over two lines.")
version_cmd.release_command(title=None, dry_run=False)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
# --- version bump ----------------------------------------------------------
@@ -827,6 +885,9 @@ def test_migration_required_retracts_the_no_migration_line(tree):
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert version_mod.MIGRATION_NONE_MARKER not in changes
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-retype.md"
]
assert version_mod.BREAKING_CHANGE_MARKER in changes # untouched by the retraction
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
@@ -876,7 +937,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
)
def test_insert_changes_entry_migration_required_clears_the_no_migration_line():
def test_insert_changes_entry_migration_documents_replace_the_no_migration_line():
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
first = version_mod.insert_changes_entry(
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
@@ -884,13 +945,120 @@ def test_insert_changes_entry_migration_required_clears_the_no_migration_line():
)
second = version_mod.insert_changes_entry(
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
migration_required=True,
migration_documents=["instructions/migrations/2.0.0-retype.md"],
)
assert version_mod.MIGRATION_NONE_MARKER not in second
assert "**Migration:** required - instructions/migrations/2.0.0-retype.md\n" in second
assert version_mod.BREAKING_CHANGE_MARKER in second
assert "the feed moved" in second
# --- version bump: naming the migration documents (Gitea #184) --------------
def _migration_lines(tree: Path) -> list[str]:
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, version_mod.top_changes_version(changes))
return [line for line in section.splitlines() if line.startswith(version_mod.MIGRATION_MARKER)]
def test_bump_names_a_required_migration_document_and_a_second_bump_keeps_it(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
first = _migration_lines(tree)
assert first == ["**Migration:** required - instructions/migrations/2.0.0-retype.md"]
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert _migration_lines(tree) == first
def test_bump_lists_several_migration_documents_sorted(tree):
_migration_document(tree, "2.0.0", slug="zeta")
_migration_document(tree, "2.0.0", slug="alpha")
_migration_document(tree, "3.0.0", slug="later") # targets another base: not named
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-alpha.md, "
"instructions/migrations/2.0.0-zeta.md"
]
def test_a_later_bump_names_a_document_written_after_the_crossing(tree):
"""A plain bump finding a required document replaces `none required` -
the two lines exclude each other, and the document is the fact."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
assert _migration_lines(tree) == [
"**Migration:** required - instructions/migrations/2.0.0-retype.md"
]
def test_a_bump_drops_the_line_once_its_document_no_longer_targets_the_base(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
(tree / "instructions" / "migrations" / "2.0.0-retype.md").unlink()
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Follow-up",
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert _migration_lines(tree) == []
assert "\n\n\n" not in changes
def test_no_migration_is_refused_while_a_required_document_targets_the_base(tree):
_migration_document(tree, "2.0.0")
before = (tree / "CHANGES.md").read_text(encoding="utf-8")
with pytest.raises(typer.Exit):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
assert (tree / "CHANGES.md").read_text(encoding="utf-8") == before
def test_an_offered_migration_document_is_not_named_as_required(tree):
"""An `obligation: offered` document is an improvement an instance may
decline - 6.0.0 shipped one beside a `none required` line, correctly."""
(tree / "instructions" / "migrations" / "2.0.0-optional.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-optional\n"
"description: Optional.\nmanual: true\nmigrates_to: 2.0.0\n"
"migration_kind: assisted\nobligation: offered\n---\n\n# M\n",
encoding="utf-8",
)
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
assert _migration_lines(tree) == [f"{version_mod.MIGRATION_NONE_MARKER} - kb/ untouched"]
# --- version bump: the breaking-change note --------------------------------
+165 -38
View File
@@ -25,7 +25,8 @@ fails it too, with `kb/` untouched, which is why `--no-migration` exists at all:
boundary-crossing bumps that migrate nothing are a real case, not an escape
hatch. Hence two markers below rather than one - `BREAKING_CHANGE_MARKER`
records the break, `MIGRATION_NONE_MARKER` records the absence of the
migration. Which part a change earns stays a judgment call made before the
migration (and `MIGRATION_REQUIRED_MARKER`, in its place, names the documents
where there is one). Which part a change earns stays a judgment call made before the
bump; this module only enforces that a crossing says what it costs.
Paths are resolved through `config.ROOT` at call time rather than at import,
@@ -88,10 +89,19 @@ _BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
# The one line in a CHANGES.md entry that answers whether content has to
# change. It takes exactly one of the two forms below, never both: a bump
# writing either replaces whichever is there.
MIGRATION_MARKER = "**Migration:**"
# Written into a CHANGES.md entry whose version crosses a compatibility
# boundary that needs no content migration. `docs verify` accepts it in place
# of a migration document, so the exact string is a contract between the two.
MIGRATION_NONE_MARKER = "**Migration:** none required"
MIGRATION_NONE_MARKER = f"{MIGRATION_MARKER} none required"
# Written by every `version bump` whose candidate base a required migration
# document targets, followed by the documents' paths - the pointer an operator
# follows from the release notes to what they have to run. `docs verify` checks
# a crossing entry names every such document here.
MIGRATION_REQUIRED_MARKER = f"{MIGRATION_MARKER} required"
# Written into every CHANGES.md entry whose version crosses a compatibility
# boundary, migration or not: the swap is not drop-in, and the operator of an
# existing instance has to be told what stops working. `docs verify` checks the
@@ -703,12 +713,22 @@ def bump_entries(section: str) -> list[tuple[str, str]]:
SUMMARY_MIN_CHARS = 200
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
# Gitea #184: the 8.0.0 entry grew to 129 KB - one `###` changeset per bump,
# 80 of them - and failed as a release body twice over: Linux's argument limit
# and Gitea's 65 535-byte column. A length rule per changeset alone could not
# have stopped it, since the count grows with the bumps; so the topmost entry
# has a total, and each `###` section is one bounded paragraph per *topic*.
# Both measured in UTF-8 bytes, the unit both limits that failed count in.
# `ENTRY_MAX_BYTES` is half Gitea's column; the paragraph is measured without
# its `###` line, which is a title, not the text the budget exists to bound.
ENTRY_MAX_BYTES = 32_000
CHANGESET_MAX_BYTES = 1_000
def summary_prose(section: str) -> str:
"""The text between the bumps region (or, for an entry with none, the
heading) and the first `### <bump title>` changeset heading - the
candidate's own summary of what it did, as opposed to the per-bump detail
below it.
heading) and the first `### ` section heading - the candidate's own
summary of what it did, as opposed to the per-topic paragraphs below it.
"""
close = section.find(_BUMPS_CLOSE)
if close != -1:
@@ -721,6 +741,75 @@ def summary_prose(section: str) -> str:
return section[start:end]
def changesets(section: str) -> list[tuple[str, list[str]]]:
"""Every `### ` section of one entry as `(heading text, body lines)`.
A body runs to the next `### ` or `## ` line, or to a `---` separator,
whichever comes first; blank lines at either end of it are dropped, so
what remains is exactly the text the paragraph budget measures.
"""
lines = section.split("\n")
found: list[tuple[str, list[str]]] = []
index = 0
while index < len(lines):
if not lines[index].startswith("### "):
index += 1
continue
heading = lines[index][4:].strip()
index += 1
body: list[str] = []
while index < len(lines) and not (
lines[index].startswith(("### ", "## ")) or lines[index].strip() == "---"
):
body.append(lines[index])
index += 1
while body and not body[0].strip():
body.pop(0)
while body and not body[-1].strip():
body.pop()
found.append((heading, body))
return found
def budget_issues(text: str) -> list[str]:
"""Where the topmost versioned entry of `text` exceeds its size budget.
Measured on the text `version notes` prints for that entry - which is what
`release.yml` posts as the release body. Only the topmost entry is held to
it: older ones are already published as release notes, and a changelog
with no versioned entry (a distributed instance's stub) has nothing to
check. Each `### ` section is exactly one paragraph - no blank line in its
body - of at most `CHANGESET_MAX_BYTES`, heading line excluded.
"""
top = top_changes_version(text)
if top is None:
return []
section = changes_section(text, top) or ""
issues: list[str] = []
size = len(section.encode("utf-8"))
if size > ENTRY_MAX_BYTES:
issues.append(
f"{top}'s {CHANGES_FILENAME} entry is {size} bytes, over its {ENTRY_MAX_BYTES}-byte "
"budget - shorten or merge `###` paragraphs; a smaller change keeps only its bump title"
)
for heading, body in changesets(section):
if any(not line.strip() for line in body):
paragraphs = 1 + sum(
1 for previous, line in zip(body, body[1:]) if line.strip() and not previous.strip()
)
issues.append(
f"{top}'s section `### {heading}` has {paragraphs} paragraphs - each `###` "
"section is exactly one paragraph; rewrite it as one"
)
body_size = len("\n".join(body).encode("utf-8"))
if body_size > CHANGESET_MAX_BYTES:
issues.append(
f"{top}'s section `### {heading}` is {body_size} bytes, over the "
f"{CHANGESET_MAX_BYTES}-byte paragraph budget - move the rationale into the issue"
)
return issues
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
"""Change the impact grade of one or more of the topmost entry's bump
titles, addressed by their 1-based position in `bump_entries`'s rendered
@@ -759,11 +848,13 @@ def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
def _set_marker_line(section: str, marker: str, line: str) -> str:
"""Add or replace the one-line `marker ...` paragraph in `section`.
Used for the no-migration line, which - unlike the bumps list and unlike
Used for the migration line, which - unlike the bumps list and unlike
the breaking-change paragraph below - is **not** accumulated: it answers
one yes/no question about the candidate as a whole ("does content have to
change?"), so a second answer replaces the first rather than joining it,
and `_clear_marker_line` is its retraction path.
change?"), so a second answer replaces the first rather than joining it.
Called with the shared `MIGRATION_MARKER`, so a `none required` line and a
`required - <paths>` line replace each other and never stand side by side;
`_clear_marker_line` removes either.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
@@ -845,17 +936,56 @@ def _add_breaking_reason(section: str, reason: str) -> str:
def _clear_marker_line(section: str, marker: str) -> str:
"""Remove the one-line `marker ...` paragraph from `section`, if present.
"""Remove the one-line `marker ...` paragraph from `section`, if present,
together with the blank line that separated it from the next paragraph.
The retraction counterpart to `_set_marker_line`. A candidate that
recorded `--no-migration` and later turns out to need one after all has no
other way to take that statement back - the line is machine-managed, and
invariant 1 forbids hand-editing it.
The counterpart to `_set_marker_line`: it drops a `required - <paths>`
line whose documents no longer target the candidate's base - the line is
machine-managed, and invariant 1 forbids hand-editing it.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*\n?", re.MULTILINE)
pattern = re.compile(rf"^{re.escape(marker)}.*\n?(?:[ \t]*\n)?", re.MULTILINE)
return pattern.sub("", section, count=1)
def migration_required_line(paths: list[str]) -> str:
"""The `**Migration:** required - <paths>` line, paths sorted and
comma-separated so the same set always renders byte-identically."""
return f"{MIGRATION_REQUIRED_MARKER} - {', '.join(sorted(paths))}"
_MIGRATION_REQUIRED_RE = re.compile(
rf"^{re.escape(MIGRATION_REQUIRED_MARKER)}(?: - (.*))?$", re.MULTILINE
)
def migration_paths(section: str) -> list[str]:
"""The document paths an entry's `**Migration:** required` line names, in
written order - empty when the entry carries no such line."""
match = _MIGRATION_REQUIRED_RE.search(section)
if not match or not match.group(1):
return []
return [path.strip() for path in match.group(1).split(",") if path.strip()]
def _record_migration(
section: str, no_migration_reason: Optional[str], migration_documents: list[str]
) -> str:
"""Bring the migration line in line with this bump's answer.
A required document targeting the base is a fact about the candidate, so
its `required` line is written on every bump that finds one - replacing a
`none required` line, since content does have to change after all. Without
one, an explicit `--no-migration` reason writes the `none required` form;
with neither, a `required` line left over from documents that have since
stopped targeting the base is dropped, and a `none required` line stays.
"""
if migration_documents:
return _set_marker_line(section, MIGRATION_MARKER, migration_required_line(migration_documents))
if no_migration_reason:
return _set_marker_line(section, MIGRATION_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
return _clear_marker_line(section, MIGRATION_REQUIRED_MARKER)
def _entry_span(text: str) -> tuple[int, int]:
"""Start/end offsets of the topmost entry, heading included."""
match = re.search(r"^## ", text, re.MULTILINE)
@@ -874,22 +1004,18 @@ def _update_open_candidate(
title: str,
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
migration_required: bool = False,
migration_documents: list[str],
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`(impact, title)` to its machine-managed bump list, and record the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
`(impact, title)` to its machine-managed bump list, record a breaking
reason only where this call supplies one, and bring the migration line in
line with this bump's answer - see `insert_changes_entry`.
The two are recorded differently on purpose: a `breaking_reason` **joins**
whatever crossings the candidate already recorded (`_add_breaking_reason`),
a `no_migration_reason` **replaces** the single line that answers whether
content has to change (`_set_marker_line`).
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
the caller (`version_cmd.bump_command`), not here."""
while the migration line is one answer about the whole candidate that a
later bump **replaces** (`_record_migration`)."""
start, end = _entry_span(text)
section = text[start:end]
@@ -904,10 +1030,7 @@ def _update_open_candidate(
if breaking_reason:
section = _add_breaking_reason(section, breaking_reason)
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
elif migration_required:
section = _clear_marker_line(section, MIGRATION_NONE_MARKER)
section = _record_migration(section, no_migration_reason, migration_documents)
return text[:start] + section + text[end:]
@@ -920,7 +1043,7 @@ def insert_changes_entry(
author: str,
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
migration_documents: Optional[list[str]] = None,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Open a new entry above the newest existing one, or - when the topmost
@@ -937,32 +1060,36 @@ def insert_changes_entry(
compatibility boundary is crossed - the line saying what breaks (one
crossing, so the flat one-line form; a candidate that crosses again
accumulates bullets there, see `_add_breaking_reason`), plus the
line saying no content has to change where that applies, and then the
migration line where there is an answer to give, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than
below it (Gitea #95): it is what an operator reading the release notes has
to act on, the migration line only qualifies it, and neither should sit
beneath a list that can run to dozens of graded entries. The entry's
actual prose - the release summary, and each bump's own changeset - is
written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
actual prose - the release summary and the one-paragraph `###` sections,
one per topic rather than one per bump - is written afterwards by whoever
made the change, which is also why `bump` refuses to invent a title.
`migration_required` only has anything to retract on an already-open
candidate, so a fresh entry ignores it - there is no earlier
`--no-migration` line in a skeleton that was just opened.
`migration_documents` are the paths of the required migration documents
targeting `version.base`; where there are any, they are the migration
line (`**Migration:** required - <paths>`), replacing a `none required`
line - see `_record_migration`.
"""
documents = list(migration_documents or [])
top = top_changes_version(text)
if top is not None and top.is_prerelease:
return _update_open_candidate(
text, version, date, title,
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
migration_required=migration_required, impact=impact,
migration_documents=documents, impact=impact,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason:
if documents:
lines += [migration_required_line(documents), ""]
elif no_migration_reason:
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
if version.is_prerelease:
lines += [_bumps_block([(impact, title)]), ""]