diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5e8e99..ba7299e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,11 +29,11 @@ jobs: # The release body comes from CHANGELOG.md, never from generated notes. # `generate_release_notes` built it out of PR titles and authors, which - # put a real committer identity into a public release ("@Cipher208 and - # Murat"). Notes written by hand in the CHANGELOG say what changed and - # why; a list of who touched it says neither. Failing here when the - # section is missing is deliberate — an empty body is better than a - # silently generated one, and the tag would be mis-built anyway. + # put a real committer identity into a public release. Notes written by + # hand in the CHANGELOG say what changed and why; a list of who touched + # it says neither. Failing here when the section is missing is + # deliberate — an empty body is better than a silently generated one, + # and the tag would be mis-built anyway. - name: Take the release body from CHANGELOG.md run: | python - "${{ github.ref_name }}" > RELEASE_BODY.md <<'PY' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd8c34..b141c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). - **Auto-hooks foundation (Phase C)** — external event dispatcher (`hooks/external.py::dispatch_event`) with two transports: `POST /api/hooks/{event}` (HTTP harnesses) and the new `memory_hook` MCP tool (stdio agents; primitive tier, tool count 37→38). Seven events with day-one handlers: `session_started` (returns the critical inject block), `session_ended` (session summary → L3), `new_message`/`auto_save_candidate` (`evaluate_importance` heuristic — no LLM; score ≥ 0.5 → L3 episodic + graph node, ≥ 0.8 → also L4 core), `post_context_compression` (rehydrate candidates via retrieval), `context_threshold`/`memory_pressure` (thin advice; decisions stay harness-side). New `POST /api/context-inject` returns the budget-capped critical set (ACT-R top-5 relevant when a query is given + recent L1 (24h) + important facts ≥ `inject.important_min`). Graph is now threaded through the hook registry (`fire(..., graph=)`, C1.1). Per-agent isolation inherited (own process + `MCP_MEMORY_DATA_DIR`; D1.13 API-key binding applies to both surfaces). Harness-side daemon (C1.9) is the next step. ### Changed -- **The release body is the CHANGELOG now, not generated notes (2026-10-08).** `release.yml` published releases with `generate_release_notes: true`, which composes the body out of PR titles and author handles — the draft it left behind ended in a real committer identity, "@Cipher208 and Murat". A note that lists who touched a change says neither what changed nor why. The workflow now extracts the `## []` section from `CHANGELOG.md` into the release body and **fails loudly** when that section is missing, so a tag with no written notes cannot silently become a generated list. The same author handles came out of `release-drafter.yml` (`@$AUTHOR` in `change-template`, plus a `$CONTRIBUTORS` block): the drafter stays as a working aid, but the published text comes from the file a human wrote. The dead `v1.10.0` draft — never tagged, body generated — was deleted. +- **The release body is the CHANGELOG now, not generated notes (2026-10-08).** `release.yml` published releases with `generate_release_notes: true`, which composes the body out of PR titles and author handles — the draft it left behind ended in a real committer identity, one that is not meant to appear anywhere in this repository's public output. A note that lists who touched a change says neither what changed nor why. The workflow now extracts the `## []` section from `CHANGELOG.md` into the release body and **fails loudly** when that section is missing, so a tag with no written notes cannot silently become a generated list. The same author handles came out of `release-drafter.yml` (`@$AUTHOR` in `change-template`, plus a `$CONTRIBUTORS` block): the drafter stays as a working aid, but the published text comes from the file a human wrote. The dead `v1.10.0` draft — never tagged, body generated — was deleted. This entry itself first quoted that identity while explaining the problem, which is the same leak in a different file, and is why `tests/test_release_hygiene.py` exists rather than another round of reading carefully: it extracts every released section the way the workflow does, and checks it against the anonymizer's own dictionary (`privacy._ru_personas()`), so the guard cannot drift from the list it protects. Verified red: putting the identity back into this file fails the guard, which reports the name it found — the first attempt at writing this very sentence was caught by it. - **The bug-report form asked for a version from a package that does not exist (2026-10-08).** `pip show mcp-ariel-memory` cannot resolve: the distribution on PyPI is **`a-memory`**, and `mcp-ariel-memory` is the name of a console script (one of three). Anyone following the form got "Package(s) not found". Now `pip show a-memory`. diff --git a/tests/test_primitives.py b/tests/test_primitives.py index 47673e5..787f1e8 100644 --- a/tests/test_primitives.py +++ b/tests/test_primitives.py @@ -25,7 +25,7 @@ def test_auto_route_agent_voice(): def test_auto_route_user_facts(): assert _auto_route("the user likes short answers") == "user" - assert _auto_route("Murat prefers Russian for conversation") == "user" + assert _auto_route("the user prefers Russian for conversation") == "user" def test_auto_route_tie_defaults_user(): diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py new file mode 100644 index 0000000..3f1062c --- /dev/null +++ b/tests/test_release_hygiene.py @@ -0,0 +1,99 @@ +"""Release hygiene: the published body must not carry a persona identity. + +Why this test exists rather than a careful reading. + +`release.yml` used to publish releases with `generate_release_notes: true`, which +composes the body from PR titles and commit authors. The draft it left behind +closed with a real committer identity — a name and a handle that are not meant to +appear anywhere in this repository's public output. Fixing the workflow to build +the body from `CHANGELOG.md` removed that particular source, but the same leak +came straight back twice within the same change: + +* the CHANGELOG entry explaining the fix *quoted* the identity it was removing; +* the workflow comment did the same, in a second file. + +Both were caught by reading, and reading was the thing that had already failed. +So the rule lives here now: whatever `release.yml` extracts and publishes is +checked against the anonymizer's own persona dictionary, in the form it will be +published. The dictionary is read from `privacy._ru_personas()` — the same +frozenset the ru privacy tier masks with — so this test cannot drift from the +list it is protecting. + +The identity list itself is not the bug and is not touched: `config.yaml` +`rag.ru_personas`, `rag/synonyms.py` and the privacy tests *are* the feature that +hides these names. This test only forbids them from reaching release output. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from mcp_server.utils.privacy import _ru_personas + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = REPO_ROOT / "CHANGELOG.md" + +# The extraction in .github/workflows/release.yml, kept identical on purpose. +# If that regex changes, this test must be changed with it — a mismatch would +# make the guard check a body the release never publishes. +_SECTION = re.compile( + r"^## \[(?P[^\]]+)\][^\n]*\n(.*?)(?=^## \[|\Z)", + re.MULTILINE | re.DOTALL, +) + + +def _release_body(version: str) -> str: + """Return the section `release.yml` would publish for `version`.""" + text = CHANGELOG.read_text(encoding="utf-8") + for match in _SECTION.finditer(text): + if match.group("version") == version: + return match.group(2).strip() + raise AssertionError(f"CHANGELOG.md has no '## [{version}]' section") + + +def _versions() -> list[str]: + return [ + m.group("version") + for m in _SECTION.finditer(CHANGELOG.read_text(encoding="utf-8")) + if not m.group("version").lower().startswith("unreleased") + ] + + +def _occurrences(text: str, persona: str) -> list[str]: + """Whole-word, case-sensitive hits — `Eli` must not match `Eliot`.""" + return [m.group(0) for m in re.finditer(rf"(? None: + # Guards the guard: a broken regex would silently check nothing at all. + versions = _versions() + assert versions, "no released version sections found in CHANGELOG.md" + assert "1.11.0" in versions + + +def test_1_11_0_body_is_extractable_and_non_empty() -> None: + # The workflow fails the release when this is empty; catch it before a tag. + body = _release_body("1.11.0") + assert body.startswith("### ") + assert len(body) > 1000 + + +@pytest.mark.parametrize("version", _versions()) +def test_release_body_carries_no_persona_identity(version: str) -> None: + body = _release_body(version) + leaked = {p: hits for p in _ru_personas() if (hits := _occurrences(body, p))} + assert not leaked, ( + f"release body for v{version} carries persona identities {sorted(leaked)}; " + "they are published to GitHub Releases and PyPI, and the changelog is not " + "the place to quote the name being removed" + ) + + +def test_persona_dictionary_is_not_empty() -> None: + personas = _ru_personas() + assert len(personas) >= 10, f"anonymizer dictionary shrank to {len(personas)}: {sorted(personas)}" + # The two that leaked into release text during this change, by construction. + assert "Murat" in personas and "Эли" in personas