265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
|
|
|
|
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
|
|
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
|
|
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
|
|
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
|
|
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
|
|
pre-loop skill set, so it strands those same assets plus the whole
|
|
``multi-agent-mux-loop`` skill.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
# Framework assets that live outside .agents/skills/ and must still be
|
|
# refreshed. Mapped as {installed path: path in the source tree} because the
|
|
# user manual is installed from deploy/, not from .agents/.
|
|
NON_SKILL_ASSETS = {
|
|
".agents/hooks.json": ".agents/hooks.json",
|
|
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
|
|
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
|
|
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
|
|
".agents/INSTALL.md": "deploy/INSTALL.md",
|
|
}
|
|
|
|
MARK = "UPSTREAM-RELEASE-MARKER"
|
|
|
|
|
|
@pytest.fixture
|
|
def src_and_target():
|
|
"""A pristine copy of the working tree plus an empty install target.
|
|
|
|
Runtime directories are excluded so the fixture stays fast and so the
|
|
source can never be confused with an already-installed workspace.
|
|
"""
|
|
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
|
|
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
|
|
shutil.copytree(REPO_ROOT, src,
|
|
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
|
|
".mam_deploy", "__pycache__"),
|
|
symlinks=True)
|
|
os.makedirs(tgt)
|
|
try:
|
|
yield src, tgt
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def _install(src, tgt, **extra):
|
|
env = dict(os.environ)
|
|
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
|
|
"MAM_SKIP_GITIGNORE": "1"})
|
|
env.update(extra)
|
|
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
|
|
env=env, capture_output=True, text=True)
|
|
|
|
|
|
def _bump(src, rel):
|
|
"""Simulate an upstream release that changed ``rel``."""
|
|
path = os.path.join(src, rel)
|
|
assert os.path.exists(path), "no such source asset: %s" % rel
|
|
if rel.endswith(".json"):
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
data["_release"] = MARK
|
|
with open(path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
else:
|
|
with open(path, "a") as f:
|
|
f.write("\n# %s\n" % MARK)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-1 — a refresh install must deliver upstream changes to EVERY framework
|
|
# asset, not only to those under .agents/skills/.
|
|
# --------------------------------------------------------------------------
|
|
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
for source_rel in NON_SKILL_ASSETS.values():
|
|
_bump(src, source_rel)
|
|
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
|
|
|
|
res = _install(src, tgt)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
|
|
assert MARK in f.read(), (
|
|
"control failed: even a skills/ file did not refresh, so this test "
|
|
"is not measuring what it claims")
|
|
|
|
stale = []
|
|
for rel in NON_SKILL_ASSETS:
|
|
with open(os.path.join(tgt, rel)) as f:
|
|
if MARK not in f.read():
|
|
stale.append(rel)
|
|
assert not stale, (
|
|
"install.sh refreshed .agents/skills/ but left these framework assets "
|
|
"at their originally-installed version: %s. The O-3 guard and the "
|
|
"MULTI_AGENT_RULES protocol therefore never reach an existing "
|
|
"workspace." % stale)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-2 — remove.sh's manifest-less fallback must clear every asset the
|
|
# installers write, or the next install is blocked by the survivors
|
|
# (install.sh only copies a non-skill asset when it does not exist).
|
|
# --------------------------------------------------------------------------
|
|
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
# An install_mam.sh deployment leaves no manifest; force that code path.
|
|
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
|
|
|
|
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
|
|
"--force", tgt], capture_output=True, text=True)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
survivors = []
|
|
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
|
|
for f in files:
|
|
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
|
|
assert not survivors, (
|
|
"the manifest-less fallback left framework assets behind: %s. Because "
|
|
"install.sh copies a non-skill asset only when it is absent, these "
|
|
"survivors permanently pin the workspace to the old version."
|
|
% sorted(survivors))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
|
|
# take the exact-reversal path instead of the frozen fallback list.
|
|
# --------------------------------------------------------------------------
|
|
def test_d3_install_mam_records_a_manifest():
|
|
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
|
|
assert "install_manifest.txt" in body, (
|
|
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
|
|
"workspace it deploys is uninstalled and updated through remove.sh's "
|
|
"frozen fallback list")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
|
|
# plausibly drop, including the O-3 hook pair.
|
|
# --------------------------------------------------------------------------
|
|
def test_d5_asset_presence_gate_covers_the_hook_pair():
|
|
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
|
start = body.index("check_assets_present()")
|
|
gate = body[start:body.index("}", body.index("return 0", start))]
|
|
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
|
|
assert rel in gate, (
|
|
"check_assets_present() does not require %s, so a fetch that "
|
|
"silently dropped the O-3 guard still passes validation and "
|
|
"installs a workspace whose guard is inert" % rel)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
|
|
# "preserve modified files" backup. Framework files outside skills/ get
|
|
# neither today.
|
|
# --------------------------------------------------------------------------
|
|
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
|
|
db = f.read()
|
|
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
|
|
assert not missing, (
|
|
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
|
|
"Local edits to them are silently destroyed by remove.sh instead of "
|
|
"being backed up." % missing)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-7 — every variable the installer writes into a fresh .mam.env must be
|
|
# documented in the committed template.
|
|
# --------------------------------------------------------------------------
|
|
def test_d7_env_template_documents_installer_applied_defaults():
|
|
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
|
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
|
|
end_idx = installer.index("EOF", start_idx + 15)
|
|
block = installer[start_idx:end_idx]
|
|
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
|
|
if "=" in ln and not ln.strip().startswith("#")]
|
|
|
|
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
|
|
undocumented = [v for v in written if v not in template]
|
|
assert not undocumented, (
|
|
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
|
|
"never mentions them, so users cannot discover or correct them"
|
|
% undocumented)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-8 — CI must lint the shipped hook and run the test suite that every
|
|
# recent feature commit claims as its evidence.
|
|
# --------------------------------------------------------------------------
|
|
def test_d8_ci_lints_the_hook_and_runs_the_tests():
|
|
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
|
|
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
|
|
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
|
|
"the O-3 hook")
|
|
assert "pytest" in ci, (
|
|
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
|
|
"ever gate a release")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
|
|
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
|
|
# therefore never fire. One of the two must not exist, or the herdr
|
|
# migration's doc update silently loses to the tmux-era copy.
|
|
# --------------------------------------------------------------------------
|
|
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
|
|
shipped = f.read()
|
|
assert "tmux" not in shipped, (
|
|
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
|
|
"stages .agents/INSTALL.md from the repo first, so its later "
|
|
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
|
|
"up-to-date manual is never delivered")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-10 — a preserved customization must stay preserved across REPEATED
|
|
# refreshes. The hash DB was rebuilt from the working copy, so the
|
|
# second refresh saw target == db, classified the file as unmodified,
|
|
# and overwrote it with no notice.
|
|
# --------------------------------------------------------------------------
|
|
def test_d10_customization_survives_repeated_refresh(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
|
|
with open(skill, "a") as f:
|
|
f.write("\n# MY_LOCAL_TWEAK\n")
|
|
|
|
for n in (2, 3, 4):
|
|
if n == 4: # upstream also moves on
|
|
_bump(src, ".agents/skills/lib.sh")
|
|
res = _install(src, tgt)
|
|
assert res.returncode == 0, res.stderr
|
|
with open(skill) as f:
|
|
body = f.read()
|
|
assert "MY_LOCAL_TWEAK" in body, (
|
|
"the local customization was destroyed on refresh #%d. install.sh "
|
|
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
|
|
"the first PRESERVE the modified hash becomes the canonical one "
|
|
"and the next refresh silently overwrites it." % n)
|
|
assert "Local modification detected" in res.stderr, (
|
|
"refresh #%d overwrote nothing but also reported nothing; the "
|
|
"user gets no signal that their edit is diverging" % n)
|