Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## [<tag>]` 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 `## [<tag>]` 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`.

Expand Down
2 changes: 1 addition & 1 deletion tests/test_primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
99 changes: 99 additions & 0 deletions tests/test_release_hygiene.py
Original file line number Diff line number Diff line change
@@ -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<version>[^\]]+)\][^\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"(?<![A-Za-zА-Яа-яЁё]){re.escape(persona)}(?![A-Za-zА-Яа-яЁё])", text)]


def test_versions_are_discovered() -> 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
Loading