tools: command records, Distribution and versioning group - one line per cause, examples, prohibitions (#142)
Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/version_cmd.py
This commit is contained in:
1 parent
df8ff2fa22
commit
9d6ca6b193
5 files changed
+710
-265
No files matched your search
+180
-102
@@ -467,12 +467,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
if source.is_file():
|
||||
plan[relative] = _read_planned_file(source, relative)
|
||||
|
||||
# `raw/` and `incoming/` are both flat now (Gitea #67 removes type
|
||||
# `raw/` and `incoming/` are both flat (Gitea #67 removed type
|
||||
# subdirectories from the addressing scheme entirely - a file's location
|
||||
# under `raw/` is a date shard computed by `raw accept`, never a hand-picked
|
||||
# type). `.gitignore` (also exported, see ROOT_FILES) excludes `incoming/`
|
||||
# again once the instance is a git repo, which is why bootstrap.md
|
||||
# re-creates it for a plain clone that never had this export step at all.
|
||||
# type), so an export creates no subdirectories under either root. The
|
||||
# exported `.gitignore` excludes what lands in `incoming/` but not its
|
||||
# `.gitkeep` (`/incoming/*` plus `!/incoming/.gitkeep`, Gitea #88), so the
|
||||
# anchor is tracked and a plain clone of the instance has the directory
|
||||
# without any bootstrap step re-creating it.
|
||||
plan["raw/.gitkeep"] = PlannedFile("")
|
||||
plan["incoming/.gitkeep"] = PlannedFile("")
|
||||
|
||||
@@ -583,36 +585,63 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
|
||||
atomic="Yes - nothing is written until every file is planned",
|
||||
budget=cli_contract.Budget.COUNTED,
|
||||
),
|
||||
notes="Write a contentless, distributable copy of this repo's machinery into an empty "
|
||||
"`<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any "
|
||||
"`dist:strip-start`...`dist:strip-end` marker region removed, `instructions/` "
|
||||
"(minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas "
|
||||
"re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no "
|
||||
"venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus "
|
||||
"`.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors "
|
||||
"`raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location "
|
||||
"under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer "
|
||||
"creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and "
|
||||
"survives becoming a git repository, so a plain clone gets the directory without any "
|
||||
"bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus "
|
||||
"`kb/CONVENTIONS.md.template` and each collection's contract re-keyed as "
|
||||
"`kb/<name>/COLLECTION.md.template` (the templates ship; the filled "
|
||||
"`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` "
|
||||
"never do - all of them bind their instance and none are the stack's to decide, and "
|
||||
"`find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp "
|
||||
"(version, export date, origin, and a sha256 per exported file - the base a later upgrade "
|
||||
"would compare against). The four origin options only fill stamp fields: `export` never "
|
||||
"calls git and cannot discover them. Refuses a non-empty target, and a tree with no "
|
||||
"`VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that "
|
||||
"reconstructs a distributed instance into a dev instance - work on the stack in the origin "
|
||||
"repo (or a new dev instance exported from it) instead",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="Target exists and is not empty, is not a directory, or the tree has no "
|
||||
"readable `VERSION`",
|
||||
reaction="Point `<target>` at an empty (or new) directory and retry. Never merge into a "
|
||||
"non-empty one by hand",
|
||||
),),
|
||||
notes=(
|
||||
"Writes a contentless, distributable copy of this repo's machinery into an empty or "
|
||||
"new `<target>` directory.",
|
||||
"Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` "
|
||||
"marker region removed, `instructions/` (minus `instructions/dev/`), `types/` (the "
|
||||
"`root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own "
|
||||
"verbatim), `docs/` verbatim, `tools/` (no venv/caches), the "
|
||||
"`.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, "
|
||||
"`kb/CONTRACT.md` (no pages, no areas) and `VERSION`.",
|
||||
"Ships `raw/` and `incoming/` as flat roots, each with a `.gitkeep` and no "
|
||||
"subdirectories. `incoming/.gitkeep` is trackable and survives becoming a git "
|
||||
"repository, so a plain clone gets the directory without any bootstrap step.",
|
||||
"Ships templates, never the filled files: `USER.md.template`/`SOUL.md.template`, "
|
||||
"`kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as "
|
||||
"`kb/<name>/COLLECTION.md.template`. The filled "
|
||||
"`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/"
|
||||
"`types/<page-type>.md` bind their instance; `find_leaks` refuses a plan carrying one.",
|
||||
"Writes a generated `.wikitool-release.json` stamp: version, export date, origin, and "
|
||||
"a sha256 per exported file - the base a later upgrade compares against.",
|
||||
"The four origin options only fill stamp fields: `export` never calls git and cannot "
|
||||
"discover them.",
|
||||
"One-way: no command reconstructs a distributed instance into a dev instance - work on "
|
||||
"the stack in the origin repo, or in a new dev instance exported from it.",
|
||||
"`--dry-run` lists every file it would write, and writes nothing.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="`<target>` exists and is not empty, or is not a directory",
|
||||
reaction="Point `<target>` at an empty (or new) directory and retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The tree has no readable `VERSION`",
|
||||
reaction="Fix `VERSION`, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="A licence file is missing from the source tree",
|
||||
reaction="Restore it, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The plan would carry this instance's own data (a filled personalization, "
|
||||
"conventions or page type-spec file)",
|
||||
reaction="Report it: it is an allowlist bug in `dist export`, not something to "
|
||||
"work around by deleting files from the target",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool dist export ../my-wiki --dry-run",
|
||||
"tools/wikitool dist export ../my-wiki",
|
||||
),
|
||||
never=(
|
||||
"Never merge an export into a non-empty directory by hand.",
|
||||
),
|
||||
see_also=(
|
||||
"`instructions/setup-instance.md` - what comes after the export",
|
||||
"`wikitool dist upgrade` - applies a later export to an existing instance",
|
||||
"`wikitool version show` - reads the stamp this writes",
|
||||
),
|
||||
))
|
||||
@app.command("export")
|
||||
def export_command(
|
||||
@@ -961,76 +990,119 @@ def _report_plan(
|
||||
properties=cli_contract.Properties(
|
||||
effect=cli_contract.Effect.WRITE,
|
||||
idempotent=cli_contract.Idempotent.NO,
|
||||
atomic="**Yes for the refusal cases above - nothing is written.** Once writing starts "
|
||||
"it is a plain sequential file copy with no partial-state cleanup: an interruption "
|
||||
"mid-copy (killed process, disk full) can leave the tree part-old, part-new",
|
||||
atomic="Yes for every refusal - nothing is written. Once writing starts it is a plain "
|
||||
"sequential file copy with no partial-state cleanup: an interruption mid-copy "
|
||||
"(killed process, disk full) can leave the tree part-old, part-new",
|
||||
budget=cli_contract.Budget.COUNTED,
|
||||
),
|
||||
notes="Never downloads anything: `<source>` is an already-fetched export directory or "
|
||||
"`.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, "
|
||||
"does not block, if it is absent), which must unpack to exactly one top-level directory - "
|
||||
"the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* "
|
||||
"`.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank "
|
||||
"template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or "
|
||||
"seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - "
|
||||
"`chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every "
|
||||
"candidate path is classified against the *local* `.wikitool-release.json`'s recorded "
|
||||
"digest for it: unchanged is overwritten silently, absent from the old stamp is created, "
|
||||
"and locally modified or locally deleted is **never** silently overwritten - the run aborts "
|
||||
"with the full list, and its text names the three answers with the command line already "
|
||||
"filled in, so that no reader takes any of them for the default. `--keep-local` proceeds "
|
||||
"and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the "
|
||||
"release's version over the named path, discarding the local change, and re-creates it if "
|
||||
"it was locally deleted. The two are decided per path and compose on one call: without "
|
||||
"`--keep-local`, a locally changed path that no `--take-release` names still aborts the "
|
||||
"run. A `--take-release` path that this run does not report as locally changed is refused, "
|
||||
"in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a "
|
||||
"state of the tree, and a path that silently did nothing would report a successful upgrade "
|
||||
"while keeping the change it was asked to discard. After a `--keep-local` run the new stamp "
|
||||
"is still written whole, so it records the release's digest for files that were "
|
||||
"deliberately *not* written: the stamp is the baseline for the next comparison, not a "
|
||||
"literal inventory of what is on disk. That is what keeps a skipped file diverging - and "
|
||||
"therefore reported - on every later run, rather than quietly reading as current once it "
|
||||
"has been skipped once. A path taken with `--take-release` is the opposite case and the "
|
||||
"reason the flag exists: it was written, so it matches the digest the stamp records and "
|
||||
"stops being reported at all. A path in the old stamp but not the new one is reported as no "
|
||||
"longer part of the release and left alone unless `--prune` is passed, which removes it "
|
||||
"only if it is still unchanged since installation. Reports the migration chain the new "
|
||||
"machinery would owe (`chemenu.kb_state.chain` over the *new* tree's "
|
||||
"`instructions/migrations/`, read via a `directory` argument to `load_migrations`) but "
|
||||
"never runs any of it - there is no `migrate run`. Refuses before touching the source at "
|
||||
"all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, "
|
||||
"`.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* "
|
||||
"machinery, or the working tree is dirty (not being a git repository at all is a WARN, not "
|
||||
"a refusal). Refuses after reading the source when: it carries no "
|
||||
"`VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to "
|
||||
"the installed one (equal is a no-op success), it is a pre-release (`-beta.N`) without "
|
||||
"`--pre`, or `--take-release` names a path this run does not classify as locally changed. "
|
||||
"Reports, but does not block on, a crossed compatibility boundary. Never touches git - no "
|
||||
"commit, no push (invariant 5). The closing report carries no step list of its own: "
|
||||
"everything after the swap is one order, written in `instructions/upgrade-instance.md`, "
|
||||
"which the report names and which resumes at `instructions sync`. What a human decides "
|
||||
"*before* the swap - which release, whether to take it, where the tarball comes from - is "
|
||||
"`INSTALL.md` § \"Version und Updates\"",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, "
|
||||
"a migration already outstanding against the installed machinery, a dirty working "
|
||||
"tree, a source with no `VERSION`/stamp/`files` block, a source version that is older "
|
||||
"than, equal to, or (without `--pre`) a pre-release relative to the installed one, a "
|
||||
"`--take-release` path that is not classified as locally changed (the one refusal a "
|
||||
"`--dry-run` also raises), or one or more locally changed files that neither "
|
||||
"`--keep-local` nor a `--take-release` answers for",
|
||||
reaction="For every refusal above: fix the named precondition and retry - none of them are "
|
||||
"transient. For a rejected `--take-release` path: correct it against the "
|
||||
"locally-changed list the refusal prints. For locally changed files, the refusal names "
|
||||
"all three answers with the re-run line filled in - `--take-release <path>` to write "
|
||||
"the release's version over it (which ends the divergence), `--keep-local` to leave "
|
||||
"them untouched (repeatable, and it reports the same files again on every subsequent "
|
||||
"run until they stop diverging), or reconcile by hand and retry. An interrupted write "
|
||||
"is not resumed automatically; compare the tree against the printed classification and "
|
||||
"finish or revert by hand",
|
||||
),),
|
||||
notes=(
|
||||
"Never downloads anything: `<source>` is an already-fetched export directory or "
|
||||
"`.tar.gz` release archive. An archive is verified against a sibling `.sha256` if one "
|
||||
"is present (a missing one is a WARN, not a block) and must unpack to exactly one "
|
||||
"top-level directory - the shape `.gitea/workflows/release.yml` packs.",
|
||||
"The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus "
|
||||
"what an export re-seeds from a blank template every time (`kb/log.md`, "
|
||||
"`raw/.gitkeep`) or seeds once and the instance owns from then on "
|
||||
"(`.wikitool-kb.json`, `CHANGES.md`), plus the stamp itself, always rewritten.",
|
||||
"Every candidate path is classified against the *local* `.wikitool-release.json`'s "
|
||||
"recorded digest: unchanged is overwritten silently, absent from the old stamp is "
|
||||
"created, and locally modified or locally deleted is **never** silently overwritten.",
|
||||
"Locally changed files abort the run with the full list; the abort text names the "
|
||||
"three answers with the command line filled in, and none of them is the default.",
|
||||
"`--keep-local` proceeds and leaves every locally changed file untouched. The new "
|
||||
"stamp is still written whole, recording the release's digest for files that were "
|
||||
"not written, so a skipped file keeps diverging and is reported again on every later "
|
||||
"run.",
|
||||
"`--take-release <path>` (repeatable) writes the release's version over the named "
|
||||
"path, discarding the local change, and re-creates it if it was locally deleted. The "
|
||||
"path then matches the stamp and stops being reported.",
|
||||
"The two are decided per path and compose on one call: without `--keep-local`, a "
|
||||
"locally changed path that no `--take-release` names still aborts the run.",
|
||||
"A path in the old stamp but not the new one is reported as no longer part of the "
|
||||
"release and left alone, unless `--prune` is passed, which removes it only if it is "
|
||||
"still unchanged since installation.",
|
||||
"Reports the migration chain the new machinery would owe, but never runs any of it - "
|
||||
"there is no `migrate run`.",
|
||||
"Reports, but does not block on, a crossed compatibility boundary.",
|
||||
"The local preconditions - `VERSION`, the release stamp, `.wikitool-kb.json`, no "
|
||||
"outstanding migration, a clean working tree - are checked before the source is read. "
|
||||
"Not being a git repository at all is a WARN, not a refusal.",
|
||||
"Never touches git - no commit, no push.",
|
||||
"`--dry-run` classifies and reports without writing; a pre-release (`-beta.N`) source "
|
||||
"needs `--pre`.",
|
||||
"An interrupted write is not resumed automatically: compare the tree against the "
|
||||
"printed classification and finish or revert by hand.",
|
||||
"The closing report names `instructions/upgrade-instance.md`, which carries the order "
|
||||
"for everything after the swap and resumes at `instructions sync`.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="The source's version equals the installed one - a no-op success",
|
||||
reaction="",
|
||||
code=0,
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="Local `VERSION` missing, or no local `.wikitool-release.json` with a `files` "
|
||||
"block",
|
||||
reaction="Not transient - fix the named precondition and retry. A checkout with "
|
||||
"shared git history takes stack updates with `wikitool upstream merge` instead",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="`.wikitool-kb.json` is missing",
|
||||
reaction="Run `wikitool migrate baseline <version>`, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="A migration is already outstanding against the *installed* machinery",
|
||||
reaction="Finish it first - `wikitool migrate status` names it - then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The working tree is dirty",
|
||||
reaction="Commit or stash first, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="`<source>` does not exist, fails its `.sha256`, or does not unpack to exactly "
|
||||
"one top-level directory",
|
||||
reaction="Fix the path or re-download the release archive, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The source carries no `VERSION`, `.wikitool-release.json` or `files` block",
|
||||
reaction="Point `<source>` at a distribution export, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The source's version is older than the installed one, or a pre-release "
|
||||
"without `--pre`",
|
||||
reaction="Not transient - choose another source, or pass `--pre` for a pre-release",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="A `--take-release` path this run does not classify as locally changed - the "
|
||||
"one refusal a `--dry-run` also raises",
|
||||
reaction="Correct it against the locally-changed list the refusal prints, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="Locally changed files that neither `--keep-local` nor a `--take-release` "
|
||||
"answers for; nothing was written",
|
||||
reaction="Choose one of the three answers the refusal names, re-run lines filled in: "
|
||||
"`--take-release <path>` to write the release's version over it (ends the "
|
||||
"divergence), `--keep-local` to leave them untouched (reported again on every "
|
||||
"later run until they stop diverging), or reconcile by hand and retry",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz --dry-run",
|
||||
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz",
|
||||
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz --take-release tools/README.md",
|
||||
),
|
||||
never=(
|
||||
"Never treat any of the three answers to locally changed files as the default.",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version check` - finds out whether an update exists",
|
||||
"`instructions/upgrade-instance.md` - the order after the swap",
|
||||
"`INSTALL.md` § \"Version und Updates\" - which release, whether to take it, where "
|
||||
"the tarball comes from",
|
||||
"`wikitool upstream merge` - the update path for a checkout with shared git history",
|
||||
"`wikitool migrate status` - the migrations the report names",
|
||||
),
|
||||
))
|
||||
@app.command("upgrade")
|
||||
def upgrade_command(
|
||||
@@ -1226,6 +1298,12 @@ def run_upgrade(
|
||||
dst = config.ROOT / relative
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
# The new stamp is written whole even after `--keep-local` skipped files: it is the
|
||||
# baseline for the next comparison, not a literal inventory of what is on disk. That
|
||||
# is what keeps a skipped file diverging - and therefore reported - on every later
|
||||
# run, rather than quietly reading as current once it has been skipped once. A
|
||||
# `--take-release` path is the opposite case: it was written, so it matches the
|
||||
# digest recorded here and stops being reported at all.
|
||||
shutil.copy2(stamp_path, config.ROOT / version_mod.RELEASE_STAMP_FILENAME)
|
||||
|
||||
pruned: list[str] = []
|
||||
|
||||
@@ -89,14 +89,26 @@ def _describe_origin(stamp: Optional[dict]) -> str:
|
||||
atomic="Read-only",
|
||||
budget=cli_contract.Budget.EXEMPT,
|
||||
),
|
||||
notes="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**",
|
||||
notes=(
|
||||
"Prints `VERSION` and where this tree came from: \"development tree\" without a "
|
||||
"release stamp, otherwise the export date, source commit and repository from "
|
||||
"`.wikitool-release.json`, plus the release page when the stamp records one.",
|
||||
"`--json` prints the version, its compatibility key, the stamp and the update URL.",
|
||||
"Bare `wikitool version` is an alias for this.",
|
||||
"Read-only and offline; exempt from the Iteration Budget Gate.",
|
||||
),
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="`VERSION` is missing or unparseable",
|
||||
reaction="Fix `VERSION` and retry",
|
||||
),),
|
||||
examples=(
|
||||
"tools/wikitool version show",
|
||||
"tools/wikitool version show --json",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version check` - asks whether a newer stack exists",
|
||||
"`wikitool version notes` - prints a version's release notes",
|
||||
),
|
||||
))
|
||||
@app.command("show")
|
||||
def show_command(
|
||||
@@ -141,22 +153,47 @@ def show_command(
|
||||
budget=cli_contract.Budget.EXEMPT,
|
||||
network=cli_contract.Network.YES,
|
||||
),
|
||||
notes="Ask the origin's release feed whether a newer stack exists, and whether the step "
|
||||
"crosses a compatibility boundary (`state: current|update|migration|ahead`). One of the "
|
||||
"**two** commands in `wikitool` that make a network call, and the only one whose whole job "
|
||||
"it is - `version notes` is the other, and only on a distributed instance. 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",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="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",
|
||||
reaction="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",
|
||||
),),
|
||||
notes=(
|
||||
"Asks the release feed for its latest release and compares it with `VERSION`: `state` "
|
||||
"is `current`, `update`, `migration` (the step crosses a compatibility boundary) or "
|
||||
"`ahead`.",
|
||||
"One of the **two** commands in `wikitool` that make a network call, and the only one "
|
||||
"whose whole job it is - `version notes` is the other, and only on a distributed "
|
||||
"instance.",
|
||||
"Never reached implicitly from another command, needs no key, and times out after "
|
||||
"`--timeout` seconds (default 10).",
|
||||
"The feed is `--url`, else `$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.",
|
||||
"For `update` or `migration` it prints that applying the release is a separate, manual "
|
||||
"step (`INSTALL.md` § \"Eine Instanz aktualisieren\").",
|
||||
"Read-only; exempt from the Iteration Budget Gate.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="`VERSION` is missing or unparseable",
|
||||
reaction="Fix `VERSION` and retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="The feed could not be reached, answered non-JSON, or carried no `tag_name`",
|
||||
reaction="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",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool version check",
|
||||
"tools/wikitool version check --json",
|
||||
),
|
||||
never=(
|
||||
"Never report \"up to date\" for a check that failed - an unreachable feed is not "
|
||||
"that answer.",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool dist upgrade` - applies the update this reports",
|
||||
"`wikitool version notes` - prints the release notes",
|
||||
"`INSTALL.md` § \"Version und Updates\" - what the operator decides before an update",
|
||||
),
|
||||
))
|
||||
@app.command("check")
|
||||
def check_command(
|
||||
@@ -238,33 +275,53 @@ def check_command(
|
||||
budget=cli_contract.Budget.EXEMPT,
|
||||
network=cli_contract.Network.YES,
|
||||
),
|
||||
notes="Default: this tree's `VERSION`. The `CHANGES.md` entry where there is one, and where "
|
||||
"there is not, the feed's latest release notes. The fallback exists because an instance's "
|
||||
"`CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_"
|
||||
"preserved`), so the local file can never carry the entry - not today and not after any "
|
||||
"future release, which made the command permanently unanswerable exactly where the release "
|
||||
"notes are most needed. It is reached **only with a release stamp present**, i.e. only from "
|
||||
"a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin "
|
||||
"repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed "
|
||||
"being asked, and the one naming the release that answered, go to stderr - `release.yml` "
|
||||
"redirects stdout into the file it posts as the release body. Only the feed's *latest* "
|
||||
"release can be asked for (`update_url` is the one URL a stamp records, and composing a "
|
||||
"by-tag URL out of it would be guessing at an API shape), so a returned version other than "
|
||||
"the one asked for is named on stderr and printed anyway - the expected shape before an "
|
||||
"upgrade, where `VERSION` still names the release being left. `--offline` refuses the call "
|
||||
"and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="An unparseable `--version`, an unreadable `VERSION` when `--version` is "
|
||||
"omitted, or a missing `CHANGES.md`. No entry for the requested version is an error "
|
||||
"only where the feed cannot answer either: in a tree with no release stamp (a dev "
|
||||
"checkout - write the entry, or `version bump`), with `--offline`, or when the feed "
|
||||
"could not be reached or returned a release with an empty `body`. Every one of those "
|
||||
"failures names the stamp's `release_url` where it has one, so a run that cannot read "
|
||||
"the notes is still told where they are",
|
||||
reaction="Fix the named argument or file, then retry. A feed failure is transient - retry "
|
||||
"once, then read the release page the error names. Safe to retry",
|
||||
),),
|
||||
notes=(
|
||||
"Prints the `CHANGES.md` entry for `--version` (default: this tree's `VERSION`).",
|
||||
"With no such entry, in a tree with a release stamp (a `dist export` tree) and without "
|
||||
"`--offline`, it prints the release feed's latest release notes instead. A tree "
|
||||
"without a stamp (a dev checkout) never asks the feed.",
|
||||
"Only the feed's latest release can be asked for. When that is a different version "
|
||||
"than requested, stderr names it and the notes are printed anyway - the expected case "
|
||||
"before an upgrade, where `VERSION` still names the release being left.",
|
||||
"stdout carries nothing but the notes; the line naming the feed being asked and the "
|
||||
"one naming the release that answered go to stderr. `release.yml` redirects stdout "
|
||||
"into the file it posts as the release body.",
|
||||
"`--offline` never asks the feed and fails with the stamp's `release_url` instead.",
|
||||
"Every failure names the stamp's `release_url` where it has one, so a run that cannot "
|
||||
"read the notes is still told where they are.",
|
||||
"Read-only, safe to retry, and exempt from the Iteration Budget Gate.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="`--version` is unparseable, `VERSION` is unreadable when `--version` is "
|
||||
"omitted, or `CHANGES.md` is missing",
|
||||
reaction="Fix the named argument or file, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="No entry for the requested version in a tree with no release stamp (a dev "
|
||||
"checkout)",
|
||||
reaction="Write the entry, or run `version bump`",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="No entry for the requested version, and `--offline` was passed",
|
||||
reaction="Read the release page the error names",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="No entry for the requested version, and the feed could not be reached or "
|
||||
"returned a release with an empty `body`",
|
||||
reaction="A feed failure is transient - retry once, then read the release page the "
|
||||
"error names",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool version notes",
|
||||
"tools/wikitool version notes --version 7.0.0",
|
||||
"tools/wikitool version notes --offline",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version check` - asks the same feed whether a newer stack exists",
|
||||
"`wikitool version bump` - writes the entry this prints",
|
||||
),
|
||||
))
|
||||
@app.command("notes")
|
||||
def notes_command(
|
||||
@@ -398,55 +455,90 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
|
||||
atomic="No - `VERSION` then `CHANGES.md`",
|
||||
budget=cli_contract.Budget.COUNTED,
|
||||
),
|
||||
notes="`VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. "
|
||||
"`--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. Opens the matching `CHANGES.md` entry on the first bump "
|
||||
"of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` "
|
||||
"list of every `--title` collected so far, graded by `--impact`, default `medium`) and "
|
||||
"updates that same entry in place on every later bump of the same candidate - one entry per "
|
||||
"candidate, not one per bump. The list renders grouped under "
|
||||
"`**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so "
|
||||
"far is `medium`, where it stays the flat, ungrouped list the region always had - "
|
||||
"`version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an "
|
||||
"empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. "
|
||||
"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. The two then behave differently on a "
|
||||
"*second* crossing, because they answer different questions: a further `--breaking` "
|
||||
"**joins** the ones already recorded (one reason per crossing - rendered flat on the marker "
|
||||
"line while there is only one, as bullets under a bare marker from the second onward, and "
|
||||
"repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the "
|
||||
"single line that says whether content has to change. A candidate crossing the boundary "
|
||||
"twice is the normal shape of a long-running one, and each crossing is a separate thing an "
|
||||
"operator has to act on; whether content migrates stays one yes/no about the candidate as a "
|
||||
"whole. There is deliberately no retraction path for a single accumulated `--breaking` "
|
||||
"reason - `--migration-required` retracts the migration line, and nothing retracts a "
|
||||
"breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong "
|
||||
"after all retracts that line with `--migration-required` instead of restating "
|
||||
"`--no-migration` - refused without a migration document already targeting the new base, "
|
||||
"and without an existing `--no-migration` line to retract. Which part a change earns stays "
|
||||
"a judgment call: the command enforces that a crossing documents itself, never that the "
|
||||
"part was chosen correctly",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an "
|
||||
"unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's "
|
||||
"newest entry naming different versions, an escalation to a boundary crossing without "
|
||||
"`--breaking` or with neither a migration document nor `--no-migration`, "
|
||||
"`--breaking`/`--no-migration` on a bump that crosses nothing, or `--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",
|
||||
reaction="**Not idempotent**: a second run escalates or continues the candidate again. If "
|
||||
"the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying",
|
||||
),),
|
||||
notes=(
|
||||
"`VERSION` gets a `-beta.N` suffix: one running candidate between two releases, never "
|
||||
"a fresh number per bump.",
|
||||
"`--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.",
|
||||
"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.",
|
||||
"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.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="Not exactly one of `--major`/`--minor`/`--patch`, an empty `--title`, or an "
|
||||
"unknown `--impact`",
|
||||
reaction="Nothing was written - fix the argument and retry once",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="`VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's "
|
||||
"newest entry name different versions",
|
||||
reaction="Nothing was written - fix whichever is wrong, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="An escalation to a boundary crossing without `--breaking`, or with neither a "
|
||||
"migration document targeting the new base nor `--no-migration`",
|
||||
reaction="Nothing was written - add what the error asks for, or, if nothing actually "
|
||||
"breaks, choose a smaller part",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="`--breaking` or `--no-migration` on a bump that crosses nothing",
|
||||
reaction="Nothing was written - drop the flag and 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 "
|
||||
"migration document already targeting the new base",
|
||||
reaction="Nothing was written - write the migration document or fix the "
|
||||
"combination, then retry",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
'tools/wikitool version bump --patch --title "Fix the lint report path" --impact low',
|
||||
'tools/wikitool version bump --minor --title "New command: wikitool review" --dry-run',
|
||||
'tools/wikitool version bump --major --title "Rename --gate-file to --remotes-file" '
|
||||
'--breaking "--gate-file is gone; scripts must pass --remotes-file" '
|
||||
'--no-migration "no content changes"',
|
||||
),
|
||||
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.",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version regrade` - corrects an `--impact` grade",
|
||||
"`wikitool version release` - fixes the candidate into a release",
|
||||
"`instructions/migrate-corpus.md` - how a migration document is written",
|
||||
),
|
||||
))
|
||||
@app.command("bump")
|
||||
def bump_command(
|
||||
@@ -653,28 +745,51 @@ def bump_command(
|
||||
atomic="No - `VERSION` then `CHANGES.md`",
|
||||
budget=cli_contract.Budget.COUNTED,
|
||||
),
|
||||
notes="Ends the pre-release phase `version bump` started. Without `--title` the heading "
|
||||
"keeps whichever bump last set it; with it, the heading's title is replaced - the normal "
|
||||
"case for a candidate that collected several bump titles, since the entry wants a "
|
||||
"summarising heading rather than the most recent one. Leaves the entry's machine-managed "
|
||||
"bump-title list untouched, as the record of what happened. Refuses when the candidate "
|
||||
"collected two or more bumps and the entry still carries no 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 and reports the same "
|
||||
"refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves "
|
||||
"`VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a "
|
||||
"release (no running candidate to fix), or when the changelog's newest entry does not match "
|
||||
"`VERSION`",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running "
|
||||
"candidate), `VERSION` and the changelog's newest entry naming different versions, or "
|
||||
"(from two bumps on) an entry with no summary paragraph above the changesets",
|
||||
reaction="**Not idempotent**: a second run fails outright once the suffix is gone. If the "
|
||||
"outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` "
|
||||
"means it already ran",
|
||||
),),
|
||||
notes=(
|
||||
"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.",
|
||||
"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.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="`VERSION` is already a release - there is no running candidate to fix",
|
||||
reaction="After an uncertain run this means it already ran; otherwise there is "
|
||||
"nothing to release",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="`VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest "
|
||||
"entry name different versions",
|
||||
reaction="Fix whichever is wrong, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="Two or more bumps and no summary paragraph above the changesets",
|
||||
reaction="Write a short summary paragraph right below the bump list, then retry",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool version release --dry-run",
|
||||
'tools/wikitool version release --title "Command records rewritten for agents"',
|
||||
),
|
||||
never=(
|
||||
"Never retry after an uncertain outcome without reading `VERSION` first - a "
|
||||
"release-shaped `VERSION` means it already ran.",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version bump` - opens and continues the candidate",
|
||||
"`wikitool version regrade` - shows the bump list the summary sits under",
|
||||
"`wikitool publish` - moves the released `VERSION` onto `main`",
|
||||
),
|
||||
))
|
||||
@app.command("release")
|
||||
def release_command(
|
||||
@@ -775,26 +890,45 @@ def release_command(
|
||||
atomic="No - `CHANGES.md` only, and only when indices are given",
|
||||
budget=cli_contract.Budget.EXEMPT_WITHOUT_ARGS,
|
||||
),
|
||||
notes="1-based rendered position (no arguments - the correction path for a `--impact` "
|
||||
"judgement made at bump time), or change one or more of them in a single call: "
|
||||
"`version regrade 3 7 --impact high` grades both against a single read of today's list, not "
|
||||
"position 3 first and then position 7 against whatever that produced. Touches only the "
|
||||
"topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The "
|
||||
"bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; "
|
||||
"a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an "
|
||||
"index outside the rendered list's range, an unknown `--impact`, indices given without "
|
||||
"`--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, "
|
||||
"or a topmost entry with no bump list at all",
|
||||
failures=(cli_contract.Failure(
|
||||
label="",
|
||||
cause="A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry "
|
||||
"naming different versions, a topmost entry with no bump list, an index outside the "
|
||||
"rendered list's range, indices given without `--impact`, or an unknown `--impact`",
|
||||
reaction="The bare listing never writes anything. A write is **not idempotent** against a "
|
||||
"changed list: re-running the same indices after a first success regrades whatever is "
|
||||
"at those positions *now*, which may no longer be the same bumps - list again before "
|
||||
"retrying",
|
||||
),),
|
||||
notes=(
|
||||
"Without arguments: lists the running candidate's bump titles with their grade, "
|
||||
"numbered by 1-based position in the rendered list (High before Medium before Low, "
|
||||
"chronological within a grade). Read-only and exempt from the Iteration Budget Gate.",
|
||||
"With indices and `--impact`: sets the grade of every named position in one call, all "
|
||||
"resolved against a single read of the current list - `version regrade 3 7 --impact "
|
||||
"high` grades what is at 3 and 7 now, not 7 after 3 has moved. Writes `CHANGES.md` and "
|
||||
"is counted by the Iteration Budget Gate.",
|
||||
"The correction path for an `--impact` grade judged at bump time.",
|
||||
"Touches only the topmost entry's bump list - never `VERSION`, never any other part of "
|
||||
"`CHANGES.md`.",
|
||||
"A write is not idempotent against a changed list: after a first success, the same "
|
||||
"indices may name different bumps.",
|
||||
),
|
||||
failures=(
|
||||
cli_contract.Failure(
|
||||
cause="`VERSION` or `CHANGES.md` is missing, `VERSION` and the changelog's newest "
|
||||
"entry name different versions, or the topmost entry has no bump list",
|
||||
reaction="Nothing was written - fix whichever is wrong, then retry",
|
||||
),
|
||||
cli_contract.Failure(
|
||||
cause="An index outside the rendered list's range, indices given without "
|
||||
"`--impact`, or an unknown `--impact`",
|
||||
reaction="Nothing was written - list again with the bare command, then retry once "
|
||||
"with corrected arguments",
|
||||
),
|
||||
),
|
||||
examples=(
|
||||
"tools/wikitool version regrade",
|
||||
"tools/wikitool version regrade 3 7 --impact high",
|
||||
),
|
||||
never=(
|
||||
"Never re-run the same indices after a write without listing again first - the "
|
||||
"positions may have moved.",
|
||||
),
|
||||
see_also=(
|
||||
"`wikitool version bump` - sets the grade in the first place",
|
||||
"`wikitool version release` - refuses without a summary above this list",
|
||||
),
|
||||
))
|
||||
@app.command("regrade")
|
||||
def regrade_command(
|
||||
|
||||
Reference in new issue
Block a user