Files
chemenu/tools/chemenu/tests/test_dist_upgrade.py
T
torben 72d01beef8
CI / verify (push) Successful in 45s
Release / release (push) Successful in 37s
dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung (#107)
Befund 3 aus dem getraceten 5.0.0-auf-6.0.0-Upgrade-Lauf. --keep-local behielt
die Drift und meldete sie bei jedem kuenftigen Upgrade erneut, der andere Weg
"reconcile by hand" hatte kein Werkzeug und kostete Handkopie, Vorbedingungs-
Commit und damit einen rohen git commit an Invariante 5 vorbei.

--take-release <pfad> ist wiederholbar, komponiert pro Pfad mit --keep-local,
lehnt einen nicht blockierten Pfad auch im --dry-run ab und beendet die Drift
statt sie zu uebergehen. Die Abbruchmeldung nennt jetzt alle drei Antworten mit
eingesetzter Kommandozeile und sagt, dass keine der Default ist.

Files changed:
- CHANGES.md
- VERSION
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/tests/test_dist_upgrade.py
2026-09-16 17:35:14 +02:00

589 lines
24 KiB
Python

"""Tests for `wikitool dist upgrade`: classification against the locally
installed release stamp, the write set, migration-chain reporting without
execution, and every refusal before anything is written. See Gitea #7."""
from __future__ import annotations
import hashlib
import json
import subprocess
import tarfile
from pathlib import Path
import pytest
import typer
from chemenu import config, kb_state, version as version_mod
from chemenu.commands import dist_cmd
def _digest(text: str) -> str:
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
def _write_stamp(path: Path, version: str, files: dict[str, str]) -> None:
path.write_text(
json.dumps(
{
"schema": version_mod.STAMP_SCHEMA,
"version": version,
"exported_at": "2026-01-01",
"source_repo": None,
"source_commit": None,
"release_url": None,
"update_url": version_mod.DEFAULT_UPDATE_URL,
"files": files,
},
indent=2,
),
encoding="utf-8",
)
def write_migration(directory: Path, target: str, slug: str) -> None:
directory.mkdir(parents=True, exist_ok=True)
(directory / f"{target}-{slug}.md").write_text(
"---\n"
"type: types/instruction.md\n"
f"name: {target}-{slug}\n"
f"description: Migration to {target}.\n"
"manual: true\n"
f"migrates_to: {target}\n"
"migration_kind: assisted\n"
"obligation: required\n"
"---\n\n# Migration\n\nSteps.\n",
encoding="utf-8",
)
def set_kb_version(root: Path, version: str) -> None:
(root / kb_state.KB_STATE_FILENAME).write_text(
json.dumps({"schema": 1, "kb_version": version, "applied": []}), encoding="utf-8"
)
@pytest.fixture
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal tarball instance: VERSION 1.0.0, a local release stamp
recording two tracked files exactly as installed, content stays at
1.0.0 with nothing outstanding, and no .git directory (the WARN path
for the dirty-tree check)."""
root = tmp_path / "instance"
root.mkdir()
monkeypatch.setattr(config, "ROOT", root)
(root / "VERSION").write_text("1.0.0\n", encoding="utf-8")
(root / "AGENTS.md").write_text("core\n", encoding="utf-8")
(root / "tools").mkdir()
(root / "tools" / "wikitool").write_text("#!/bin/sh\n", encoding="utf-8")
files = {
"AGENTS.md": _digest("core\n"),
"tools/wikitool": _digest("#!/bin/sh\n"),
}
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, "1.0.0", files)
set_kb_version(root, "1.0.0")
return root
def _release(tmp_path: Path, name: str, version: str, files_content: dict[str, str]) -> Path:
"""A second, independent tree shaped like a `dist export` output: VERSION,
a release stamp whose `files` block matches `files_content` exactly, and
the files themselves."""
root = tmp_path / name
root.mkdir()
(root / "VERSION").write_text(f"{version}\n", encoding="utf-8")
files: dict[str, str] = {}
for relative, content in files_content.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
files[relative] = _digest(content)
_write_stamp(root / version_mod.RELEASE_STAMP_FILENAME, version, files)
return root
# --- classification / dry-run -----------------------------------------------
def test_dry_run_classifies_every_case_and_writes_nothing(instance, tmp_path):
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
(instance / "tools" / "wikitool").unlink()
release = _release(
tmp_path, "release", "1.1.0",
{
"AGENTS.md": "core\n", # locally modified
"tools/wikitool": "#!/bin/sh\n", # locally deleted
"types/entity.md": "new page type\n", # new in the release
},
)
dist_cmd.run_upgrade(release, dry_run=True)
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
assert not (instance / "tools" / "wikitool").exists()
assert not (instance / "types" / "entity.md").exists()
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_removed_file_is_reported_and_left_alone_without_prune(instance, tmp_path):
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
# tools/wikitool is in the old stamp but absent from the new one.
dist_cmd.run_upgrade(release, dry_run=True)
assert (instance / "tools" / "wikitool").is_file()
# --- local changes are never silently overwritten ---------------------------
def test_locally_modified_file_blocks_the_upgrade_by_default(instance, tmp_path):
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
with pytest.raises(typer.Exit) as excinfo:
dist_cmd.run_upgrade(release)
assert excinfo.value.exit_code == 1
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_locally_deleted_file_blocks_the_upgrade_by_default(instance, tmp_path):
(instance / "tools" / "wikitool").unlink()
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
)
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
assert not (instance / "tools" / "wikitool").exists()
def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp_path):
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
)
dist_cmd.run_upgrade(release, keep_local=True)
# The locally changed file is untouched...
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
# ...but everything unchanged/new was still written.
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh\n"
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
def test_refusal_names_all_three_answers_with_a_pasteable_take_release_line(
instance, tmp_path, capsys
):
"""The abort a run actually reads has to rule out "the default takes the
release's version" - a real 5.0.0 -> 6.0.0 run on an instance announced
exactly that belief and then called `dist upgrade` with no flag. So the
text names all three answers, says none of them is the default, and carries
the `--take-release` line with the blocked paths already filled in."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
assert "none of these three is the default" in out
assert "--take-release AGENTS.md" in out
assert "--keep-local" in out
assert "reconcile them by hand" in out
# --- --take-release: the other answer to a locally changed file ---------------
def test_take_release_overwrites_the_named_path_and_clears_the_drift(instance, tmp_path):
"""The point of the flag, in one run rather than three hand steps: the
local change is gone, and because the new stamp records the release digest
for a file that now *matches* it, the path is no longer divergent - unlike
`--keep-local`, which reports it again on every future upgrade."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
assert stamp["version"] == "1.1.0"
# The recorded digest and the file on disk agree again, so a second run
# classifies it as unchanged rather than blocked.
assert kb_state.compare_against_stamp({"AGENTS.md": stamp["files"]["AGENTS.md"]}) == {
"AGENTS.md": kb_state.UNCHANGED
}
def test_take_release_recreates_a_locally_deleted_file(instance, tmp_path):
(instance / "tools" / "wikitool").unlink()
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
)
dist_cmd.run_upgrade(release, take_release=["tools/wikitool"])
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh v2\n"
def test_take_release_and_keep_local_compose_per_path(instance, tmp_path):
"""The mixed case is the one a blanket flag could not express: two changed
files, one to reset and one to keep."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
)
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"], keep_local=True)
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/zsh\n"
def test_a_blocked_path_not_named_by_take_release_still_aborts(instance, tmp_path):
"""Without `--keep-local` the run must say something about every blocked
path, not just the ones it happened to name."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
)
with pytest.raises(typer.Exit) as excinfo:
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
assert excinfo.value.exit_code == 1
# Nothing was written, including the path that *was* named.
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_take_release_refuses_a_path_that_is_not_locally_changed(instance, tmp_path, capsys):
"""A typo that silently did nothing would report a successful upgrade while
keeping the change the operator asked to discard."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
with pytest.raises(typer.Exit) as excinfo:
dist_cmd.run_upgrade(release, take_release=["AGENT.md"])
assert excinfo.value.exit_code == 1
out = " ".join(capsys.readouterr().out.split())
assert "AGENT.md" in out # the typo, named back
assert "AGENTS.md" in out # and the list of what *is* blocked
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
def test_a_bad_take_release_path_fails_in_the_dry_run_too(instance, tmp_path):
"""The one thing that turns `--dry-run` non-zero: not a state of the tree
(a blocked file must never do that), but a mistake in the argument, which
is exactly what a preview is for."""
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
with pytest.raises(typer.Exit) as excinfo:
dist_cmd.run_upgrade(release, dry_run=True, take_release=["types/entity.md"])
assert excinfo.value.exit_code == 1
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
def test_dry_run_marks_the_paths_take_release_would_overwrite(instance, tmp_path, capsys):
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
)
dist_cmd.run_upgrade(release, dry_run=True, take_release=["AGENTS.md"])
out = " ".join(capsys.readouterr().out.split())
assert "--take-release" in out
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
# --- the write set -----------------------------------------------------------
def test_unchanged_and_new_files_are_written_silently(instance, tmp_path):
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n", "types/entity.md": "new\n"},
)
dist_cmd.run_upgrade(release)
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
assert (instance / "types" / "entity.md").read_text(encoding="utf-8") == "new\n"
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
assert stamp["version"] == "1.1.0"
assert stamp["files"]["types/entity.md"] == _digest("new\n")
def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, capsys):
"""Everything after the swap has exactly one written order, and it is not
this line: the report names the instruction that carries it and the command
the run resumes at, rather than a second copy of the list that drifts
(AGENTS.md invariant 8). A run that reads only this output must still be
able to find the rest."""
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
)
dist_cmd.run_upgrade(release)
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
assert "instructions/upgrade-instance.md" in out
assert "instructions sync" in out
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
instance, tmp_path, preserved
):
"""The write set is the new stamp's `files` block minus what an export
re-seeds every time or seeds once and the instance owns from then on -
this is the AGENTS.md invariant 8 test: no separate literal list here,
only `chemenu.ownership`."""
(instance / "CHANGES.md").write_text("instance's own changelog\n", encoding="utf-8")
(instance / kb_state.KB_STATE_FILENAME).write_text(
json.dumps({"schema": 1, "kb_version": "1.0.0", "applied": [{"migration": "x"}]}),
encoding="utf-8",
)
release = _release(
tmp_path, "release", "1.1.0",
{
"AGENTS.md": "core\n",
"tools/wikitool": "#!/bin/sh\n",
preserved: "a fresh stub from the new release\n",
},
)
dist_cmd.run_upgrade(release)
if preserved == "CHANGES.md":
assert (instance / preserved).read_text(encoding="utf-8") == "instance's own changelog\n"
elif preserved == ".wikitool-kb.json":
state = json.loads((instance / preserved).read_text())
assert state["applied"] == [{"migration": "x"}]
else:
assert not (instance / preserved).exists()
# --- a root:kb type-spec's guidance half upgrades like any other file -------
#
# Gitea #104: before the split, `types/<name>.md` carried both the
# instance-owned template and the stack-owned authoring prose in one file, so
# an instance that had adopted it (renamed the `.template`) never received a
# prose improvement again - `dist upgrade` only ever wrote the `.template`
# beside the adopted file, never the file itself. Splitting the prose into a
# sibling `.guidance.md` that ships verbatim (never `.template`-sourced) means
# it upgrades through the ordinary unchanged/new path below, even though the
# type-spec it documents is never in the stamp at all and therefore never
# touched.
def test_upgrade_writes_improved_guidance_prose_over_an_adopted_type_spec(instance, tmp_path):
(instance / "types").mkdir()
(instance / "types" / "entity.md").write_text(
# Adopted from `types/entity.md.template` at some earlier setup - this
# file was never part of any release stamp and `dist upgrade` must
# never touch it.
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
encoding="utf-8",
)
(instance / "types" / "entity.guidance.md").write_text("old guidance\n", encoding="utf-8")
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
stamp["files"]["types/entity.guidance.md"] = _digest("old guidance\n")
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(stamp), encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{
"AGENTS.md": "core\n",
"tools/wikitool": "#!/bin/sh\n",
"types/entity.guidance.md": "improved guidance\n",
},
)
dist_cmd.run_upgrade(release)
assert (instance / "types" / "entity.guidance.md").read_text(encoding="utf-8") == (
"improved guidance\n"
)
# The adopted type-spec itself was never in either stamp, so it is
# completely untouched by the upgrade.
assert "guidance: types/entity.guidance.md" in (
instance / "types" / "entity.md"
).read_text(encoding="utf-8")
# --- migration chain: reported, never run -----------------------------------
def test_migration_chain_is_reported_but_never_executed(instance, tmp_path):
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
)
write_migration(release / "instructions" / "migrations", "1.1.0", "some-change")
dist_cmd.run_upgrade(release)
# The chain is reported, not applied: kb_version has not moved.
assert kb_state.read_kb_version().base == version_mod.Version(1, 0, 0)
def test_outstanding_local_migration_blocks_before_touching_the_source(instance, tmp_path):
"""A migration owed against the *installed* machinery must be finished
first - the source is never even opened."""
migrations = instance / "instructions" / "migrations"
write_migration(migrations, "1.0.0", "not-yet-done")
monkey_target = instance / "does-not-exist" # never read if this check fires first
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(monkey_target)
# --- preconditions -----------------------------------------------------------
def test_missing_local_stamp_blocks(instance, tmp_path):
(instance / version_mod.RELEASE_STAMP_FILENAME).unlink()
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
def test_missing_kb_version_blocks(instance, tmp_path):
(instance / kb_state.KB_STATE_FILENAME).unlink()
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
def test_prerelease_source_is_refused_without_pre_flag(instance, tmp_path):
release = _release(tmp_path, "release", "1.1.0-beta.1", {"AGENTS.md": "core\n"})
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_pre_flag_allows_a_prerelease_source(instance, tmp_path):
release = _release(
tmp_path, "release", "1.1.0-beta.1",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
)
dist_cmd.run_upgrade(release, allow_pre=True)
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0-beta.1"
def test_downgrade_is_refused(instance, tmp_path):
release = _release(tmp_path, "release", "0.9.0", {"AGENTS.md": "core\n"})
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
def test_equal_version_is_a_noop(instance, tmp_path):
release = _release(tmp_path, "release", "1.0.0", {"AGENTS.md": "core\n"})
dist_cmd.run_upgrade(release) # must not raise
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
def test_dirty_working_tree_blocks(instance, tmp_path):
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=instance, check=True)
subprocess.run(["git", "config", "user.name", "Fixture Author"], cwd=instance, check=True)
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=instance, check=True)
(instance / "untracked.txt").write_text("dirty\n", encoding="utf-8")
release = _release(tmp_path, "release", "1.1.0", {"AGENTS.md": "core\n"})
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(release)
assert not (instance / "types").exists()
def test_source_that_does_not_exist_is_refused(instance, tmp_path):
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(tmp_path / "nowhere")
# --- prune -------------------------------------------------------------------
def test_prune_removes_only_removed_files_still_unchanged_since_install(instance, tmp_path):
(instance / "extra.txt").write_text("shipped once, edited since\n", encoding="utf-8")
old_stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
old_stamp["files"]["extra.txt"] = _digest("shipped once, unedited\n") # deliberately stale
old_stamp["files"]["gone.txt"] = _digest("also shipped once\n")
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(old_stamp), encoding="utf-8")
(instance / "gone.txt").write_text("also shipped once\n", encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
)
dist_cmd.run_upgrade(release, prune=True)
# gone.txt matched its recorded digest -> pruned.
assert not (instance / "gone.txt").exists()
# extra.txt was locally edited relative to its recorded digest -> kept.
assert (instance / "extra.txt").read_text(encoding="utf-8") == "shipped once, edited since\n"
# --- tarball sources ----------------------------------------------------------
def _pack(release_dir: Path, archive: Path) -> None:
with tarfile.open(archive, "w:gz") as tf:
tf.add(release_dir, arcname=release_dir.name)
def test_tarball_with_more_than_one_top_level_entry_is_refused(instance, tmp_path):
scratch = tmp_path / "scratch"
(scratch / "a").mkdir(parents=True)
(scratch / "b").mkdir(parents=True)
(scratch / "a" / "x.txt").write_text("x\n", encoding="utf-8")
(scratch / "b" / "y.txt").write_text("y\n", encoding="utf-8")
archive = tmp_path / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
tf.add(scratch / "a", arcname="a")
tf.add(scratch / "b", arcname="b")
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(archive)
def test_tarball_source_is_extracted_and_applied(instance, tmp_path):
release_dir = _release(
tmp_path, "chemenu-stack-1.1.0", "1.1.0",
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
)
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
_pack(release_dir, archive)
dist_cmd.run_upgrade(archive)
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core\n"
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
def test_tarball_sha256_sidecar_mismatch_is_refused(instance, tmp_path):
release_dir = _release(
tmp_path, "chemenu-stack-1.1.0", "1.1.0", {"AGENTS.md": "core\n"}
)
archive = tmp_path / "chemenu-stack-1.1.0.tar.gz"
_pack(release_dir, archive)
(archive.with_name(archive.name + ".sha256")).write_text(
"0" * 64 + " chemenu-stack-1.1.0.tar.gz\n", encoding="utf-8"
)
with pytest.raises(typer.Exit):
dist_cmd.run_upgrade(archive)