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)
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:
1 parent
2ddcd6be19
commit
044943ae51
12 files changed
+718
-170
No files matched your search
+20
-8
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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)]), ""]
|
||||
|
||||
Reference in new issue
Block a user