feat: Versionsstelle als Kompatibilitaetsfrage, Breaking-Change-Vermerk erzwungen (2.5.0)
Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/dev/stack-dev/SKILL.md - instructions/dev/version-parts.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
This commit is contained in:
+2
-2
@@ -74,7 +74,7 @@ tools/wikitool <command> --help
|
||||
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR requires instances to migrate. A MAJOR bump additionally requires a migration document targeting the new version, or `--no-migration "<reason>"`, which records the reason in the entry |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) 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. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind. Read-only and **exempt from the Iteration Budget Gate** |
|
||||
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every document whose `migrates_to` lies in `(kb_version, VERSION]`. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
||||
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
|
||||
@@ -190,7 +190,7 @@ is atomic, and whether a retry is safe.
|
||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump with neither a migration document nor `--no-migration`, or `--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
||||
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
||||
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a version that is not the next link in the chain | Yes - single file write | **Not idempotent**: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order |
|
||||
|
||||
@@ -473,6 +473,43 @@ def check_migration_for_boundary() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def check_breaking_change_for_boundary() -> list[str]:
|
||||
"""A version that crosses the compatibility boundary must say what breaks.
|
||||
|
||||
Separate from `check_migration_for_boundary`, because the two answer
|
||||
different questions: that one asks whether the *content* has to move, this
|
||||
one whether the operator was told the swap is not drop-in at all. A
|
||||
boundary crossing with an untouched corpus - a renamed feed, artefact,
|
||||
import name or flag - satisfies that check and still leaves every existing
|
||||
instance with something to do by hand.
|
||||
|
||||
Only the newest entry is checked, for the same reason: older crossings are
|
||||
history, and re-reporting them forever would make the check noise.
|
||||
"""
|
||||
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 = _second_changes_version(text)
|
||||
if current is None or previous is None:
|
||||
return [] # the first versioned entry has no predecessor to cross from
|
||||
if current.compat_key == previous.compat_key:
|
||||
return []
|
||||
|
||||
if version_mod.BREAKING_CHANGE_MARKER in (version_mod.changes_section(text, current) or ""):
|
||||
return []
|
||||
|
||||
return [
|
||||
f"{current} crosses the compatibility boundary from {previous}, so it is not a drop-in "
|
||||
f"replacement - but its {version_mod.CHANGES_FILENAME} entry carries no "
|
||||
f"`{version_mod.BREAKING_CHANGE_MARKER}` line saying what stops working. Add it "
|
||||
"(`version bump --breaking` writes it; see instructions/dev/version-parts.md)"
|
||||
]
|
||||
|
||||
|
||||
@app.command("verify")
|
||||
def verify():
|
||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, and version/changelog agreement."""
|
||||
@@ -484,6 +521,7 @@ def verify():
|
||||
+ check_ignored_content()
|
||||
+ check_version_changelog()
|
||||
+ check_migration_for_boundary()
|
||||
+ check_breaking_change_for_boundary()
|
||||
)
|
||||
|
||||
if issues:
|
||||
|
||||
@@ -189,6 +189,11 @@ def bump_command(
|
||||
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
||||
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
||||
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
|
||||
breaking: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--breaking",
|
||||
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
|
||||
),
|
||||
no_migration: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--no-migration",
|
||||
@@ -203,10 +208,14 @@ def bump_command(
|
||||
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
||||
the two agree, so a bump with no entry cannot reach a release.
|
||||
|
||||
A bump that crosses the compatibility boundary additionally requires a
|
||||
migration document for the new version, or `--no-migration "<reason>"`.
|
||||
An instance learning that it must migrate, with nothing telling it how, is
|
||||
the gap this closes."""
|
||||
A bump that crosses the compatibility boundary - one whose new version is
|
||||
not a drop-in replacement, whether or not any content moves - requires
|
||||
`--breaking "<what stops working>"`, and on top of that either a migration
|
||||
document for the new version or `--no-migration "<reason>"`. An instance
|
||||
learning that it must migrate, with nothing telling it what broke or how to
|
||||
cross, is the gap these close. Which part to pass stays a judgment call
|
||||
this command does not make - it enforces only that a crossing says what it
|
||||
costs."""
|
||||
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")
|
||||
@@ -239,6 +248,23 @@ def bump_command(
|
||||
crossing = new_version.compat_key != current.compat_key
|
||||
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
||||
|
||||
if crossing and not breaking:
|
||||
fail(
|
||||
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
||||
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
||||
f"instance must do about it>\".\n"
|
||||
f"If that sentence is hard to write because nothing actually breaks - no hand-work "
|
||||
f"on update, and the old version can still be put back - then the bump is probably "
|
||||
f"not --{selected[0]}."
|
||||
)
|
||||
return
|
||||
if breaking and not crossing:
|
||||
fail(
|
||||
f"--breaking only applies to a bump that crosses the compatibility boundary; "
|
||||
f"{current} -> {new_version} does not."
|
||||
)
|
||||
return
|
||||
|
||||
if crossing and not no_migration:
|
||||
from chemenu import kb_state
|
||||
|
||||
@@ -267,6 +293,7 @@ def bump_command(
|
||||
version_mod.insert_changes_entry(
|
||||
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,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -237,6 +237,43 @@ def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkeypatch):
|
||||
"""A crossing that migrates nothing still leaves hand-work behind, so the
|
||||
migration check passing is not evidence that anyone was told."""
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||
)
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
issues = docs_verify.check_breaking_change_for_boundary()
|
||||
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
||||
)
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
docs_verify.verify()
|
||||
|
||||
|
||||
def test_verify_raises_when_a_boundary_has_no_migration(monkeypatch):
|
||||
monkeypatch.setattr(docs_verify, "check_migration_for_boundary", lambda: ["unbridged"])
|
||||
with pytest.raises(typer.Exit):
|
||||
|
||||
@@ -183,7 +183,7 @@ def test_insert_changes_entry_lands_above_the_newest_entry():
|
||||
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="Something happened",
|
||||
no_migration=None, dry_run=False,
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
@@ -194,7 +194,7 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
|
||||
def test_bump_dry_run_writes_nothing(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Nope", no_migration=None, dry_run=True
|
||||
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
@@ -207,7 +207,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
||||
major, minor, patch = flags
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=major, minor=minor, patch=patch, title="x", no_migration=None, dry_run=False
|
||||
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, dry_run=False
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
||||
def test_bump_refuses_an_empty_title(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title=" ", no_migration=None, dry_run=False
|
||||
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, dry_run=False
|
||||
)
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="x", no_migration=None, dry_run=False
|
||||
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, dry_run=False
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -241,7 +241,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
no_migration=None, dry_run=False,
|
||||
breaking="the feed moved", no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
@@ -255,7 +255,8 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
||||
encoding="utf-8",
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking", no_migration=None, dry_run=False
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
||||
|
||||
@@ -263,6 +264,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
||||
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="the release feed moved",
|
||||
no_migration="no distributed instance exists yet", dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
@@ -275,10 +277,50 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Fix",
|
||||
no_migration="not needed", dry_run=False,
|
||||
breaking=None, no_migration="not needed", dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
# --- version bump: the breaking-change note --------------------------------
|
||||
|
||||
|
||||
def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
|
||||
"""The corpus question and the drop-in question are independent: a bump
|
||||
can migrate nothing and still leave every instance with hand-work."""
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||
breaking=None, no_migration="kb/ keeps its shape", dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
def test_breaking_records_what_stops_working_in_the_changelog(tree):
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||
breaking="update_url points at a repo path that no longer exists",
|
||||
no_migration="kb/ keeps its shape", dry_run=False,
|
||||
)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||
assert "update_url points at a repo path that no longer exists" in changes
|
||||
# The break comes before the migration note: it is what an operator acts on.
|
||||
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(
|
||||
version_mod.MIGRATION_NONE_MARKER
|
||||
)
|
||||
|
||||
|
||||
def test_breaking_is_refused_on_a_compatible_bump(tree):
|
||||
"""A compatible bump that claims a break is describing itself wrongly -
|
||||
one of the two is a mistake, and the command will not guess which."""
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="New command",
|
||||
breaking="nothing, really", no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
# --- version notes ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,23 @@ ingest into a release.
|
||||
|
||||
**Compatibility is read off the leftmost non-zero component**, the rule Cargo's
|
||||
caret ranges use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from
|
||||
`1.0.0` on the same rule reads as the familiar "MAJOR means migration". Stating
|
||||
it that way is what lets the 0.x era carry the migration signal at all - under
|
||||
plain "MAJOR breaks" semantics every 0.x release would be indistinguishable
|
||||
from every other, which is exactly the signal update detection needs. Nothing
|
||||
about the mechanism changes at 1.0.0.
|
||||
`1.0.0` on the same rule reads as the familiar "MAJOR breaks". Stating it that
|
||||
way is what lets the 0.x era carry the signal at all - under a rule keyed to
|
||||
the MAJOR component alone, every 0.x release would be indistinguishable from
|
||||
every other, which is exactly the signal update detection needs. Nothing about
|
||||
the mechanism changes at 1.0.0.
|
||||
|
||||
What that component answers is **whether the new version is a drop-in
|
||||
replacement**: whether an instance can copy the new machinery over itself with
|
||||
no hand-work and still put the old version back afterwards. Whether *content*
|
||||
must be migrated is a **second, independent question**. It is one way to fail
|
||||
the first - but a renamed release feed, artefact, import name, flag or envvar
|
||||
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
|
||||
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,
|
||||
because the tests (and `dist export`'s own fixtures) relocate the root.
|
||||
@@ -64,6 +76,11 @@ _SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
||||
# 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"
|
||||
# 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
|
||||
# newest crossing carries it, so this string too is a contract between the two.
|
||||
BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
||||
# A changelog entry that names a version. Entries predating versioning start
|
||||
# with a date instead and are deliberately not matched - they are history, not
|
||||
# a claim about which version the tree is.
|
||||
@@ -332,15 +349,22 @@ def insert_changes_entry(
|
||||
title: str,
|
||||
author: str,
|
||||
no_migration_reason: Optional[str] = None,
|
||||
breaking_reason: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add a heading for `version` above the newest existing entry.
|
||||
|
||||
Only the skeleton: heading, date, author, and - when a compatibility
|
||||
boundary is crossed without a migration - the line that says so. The
|
||||
entry's actual content is written afterwards by whoever made the change,
|
||||
which is also why `bump` refuses to invent a title.
|
||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
||||
content has to change where that applies. The entry's actual content is
|
||||
written afterwards by whoever made the change, which is also why `bump`
|
||||
refuses to invent a title.
|
||||
|
||||
The break comes first: it is what an operator reading the release notes has
|
||||
to act on, and the migration line only qualifies it.
|
||||
"""
|
||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||
if breaking_reason:
|
||||
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
||||
if no_migration_reason:
|
||||
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
||||
entry = "\n".join(lines) + "\n---\n\n"
|
||||
|
||||
Reference in New Issue
Block a user