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
160 changes: 160 additions & 0 deletions .github/scripts/filter-changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Strip non-faigate content from changelog / release notes.

The prerelease workflow runs this against `git-cliff` output to drop
bullets that mention personal tooling or local setup details before the
notes hit the public GitHub release. Patterns are intentionally narrow:
when in doubt the line is kept and the human reviewer catches it.

Usage:
filter-changelog.py <input.md> # writes filtered notes to stdout
filter-changelog.py --check <input.md> # exit 1 if anything would be dropped
filter-changelog.py --self-test # run the embedded test cases
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

# Each pattern is a regex (case-insensitive). Add only when the term has
# zero legitimate place in faigate-facing release notes.
DENY_PATTERNS: list[str] = [
r"\bICM\b",
r"mempalace",
r"envctl",
r"\bRTK\b",
r"OpenCode",
r"CodeNomad",
r"Codex(?:\s+CLI)?",
r"Claude Code",
r"~/Library/",
r"~/Documents/",
r"~/\.mempalace",
r"/Users/[A-Za-z][\w.-]+",
]

_DENY_RE = re.compile("|".join(DENY_PATTERNS), re.IGNORECASE)
_BULLET_RE = re.compile(r"^\s*[-*]\s+")
_HEADING_RE = re.compile(r"^\s*#{1,6}\s+")


def filter_text(text: str) -> tuple[str, list[str]]:
"""Return ``(filtered_text, dropped_lines)``.

Drops bullet lines whose content matches any deny pattern. Then drops
headings that lose all their bullets. Collapses runs of blank lines.
Non-bullet lines are never filtered — they're prose the human wrote.
"""
lines = text.splitlines()
dropped: list[str] = []
kept: list[str] = []

for line in lines:
if _BULLET_RE.match(line) and _DENY_RE.search(line):
dropped.append(line)
continue
kept.append(line)

# Drop now-empty headings (no bullets remain in their section).
pruned: list[str] = []
i = 0
while i < len(kept):
line = kept[i]
if _HEADING_RE.match(line):
j = i + 1
section_has_content = False
while j < len(kept) and not _HEADING_RE.match(kept[j]):
if kept[j].strip():
section_has_content = True
break
j += 1
if not section_has_content:
i += 1
continue
pruned.append(line)
i += 1

result = "\n".join(pruned)
result = re.sub(r"\n{3,}", "\n\n", result)
return result.rstrip() + "\n", dropped


# ── self test ──────────────────────────────────────────────────────────


_SELF_TEST_INPUT = """\
## v2.4.0 - 2026-04-26

### Added
- feat(catalog): runtime sync engine
- chore: configured envctl cluster `faigate` with metadata vars
- ICM data archived for migration to mempalace
- Wired into Claude Code via /Users/andrelange/.claude.json

### Fixed
- fix: trailing newline on bundled snapshot

### Internal
- chore: misc cleanups in ~/Library/Caches
"""

_SELF_TEST_EXPECTED_DROPS = 4
_SELF_TEST_MUST_KEEP = [
"feat(catalog): runtime sync engine",
"fix: trailing newline on bundled snapshot",
]


def _self_test() -> int:
out, dropped = filter_text(_SELF_TEST_INPUT)
failed = False
if len(dropped) != _SELF_TEST_EXPECTED_DROPS:
print(
f"FAIL: expected {_SELF_TEST_EXPECTED_DROPS} drops, got {len(dropped)}",
file=sys.stderr,
)
for line in dropped:
print(f" dropped: {line}", file=sys.stderr)
failed = True
for keeper in _SELF_TEST_MUST_KEEP:
if keeper not in out:
print(f"FAIL: should have kept '{keeper}'", file=sys.stderr)
failed = True
if "Internal" in out:
print("FAIL: empty 'Internal' section should have been pruned", file=sys.stderr)
failed = True
if failed:
print("---OUTPUT---", file=sys.stderr)
print(out, file=sys.stderr)
return 1
print("ok: self-test passed")
return 0


def main(argv: list[str]) -> int:
if len(argv) == 2 and argv[1] == "--self-test":
return _self_test()
if len(argv) == 3 and argv[1] == "--check":
text = Path(argv[2]).read_text(encoding="utf-8")
_, dropped = filter_text(text)
if dropped:
print(f"would drop {len(dropped)} line(s):", file=sys.stderr)
for line in dropped:
print(f" {line}", file=sys.stderr)
return 1
return 0
if len(argv) == 2:
text = Path(argv[1]).read_text(encoding="utf-8")
out, dropped = filter_text(text)
sys.stdout.write(out)
if dropped:
print(f"# filtered {len(dropped)} non-faigate line(s)", file=sys.stderr)
return 0
print(__doc__, file=sys.stderr)
return 2


if __name__ == "__main__":
sys.exit(main(sys.argv))
84 changes: 84 additions & 0 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Prerelease — filtered release notes

# Runs on tag push, before release-artifacts.yml builds the dist.
# Generates a changelog with git-cliff, strips lines that mention
# non-faigate topics (personal tooling, local setup details), and
# attaches the filtered notes to the GitHub release.
#
# Content boundary policy lives in .github/scripts/filter-changelog.py
# and is documented in AGENTS.md → "Content boundary".

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to (re)generate notes for"
required: true
type: string

permissions:
contents: write

jobs:
filtered-notes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Resolve target tag
id: tag
run: |
if [ -n "${{ inputs.tag }}" ]; then
echo "ref=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi

- name: Install git-cliff
uses: orhun/git-cliff-action@v4
with:
version: latest
config: .cliff.toml
args: --tag ${{ steps.tag.outputs.ref }} --strip header --unreleased
env:
OUTPUT: raw-notes.md

- name: Self-test the filter
run: python3 .github/scripts/filter-changelog.py --self-test

- name: Apply content-boundary filter
run: |
python3 .github/scripts/filter-changelog.py raw-notes.md > filtered-notes.md
echo "--- raw notes ---"
wc -l raw-notes.md
echo "--- filtered notes ---"
wc -l filtered-notes.md
echo "--- filtered preview ---"
head -40 filtered-notes.md

- name: Update GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.ref }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --notes-file filtered-notes.md
echo "updated existing release $TAG"
else
gh release create "$TAG" --title "$TAG" --notes-file filtered-notes.md
echo "created release $TAG"
fi

- name: Upload filtered notes artifact
uses: actions/upload-artifact@v4
with:
name: filtered-release-notes
path: |
raw-notes.md
filtered-notes.md
retention-days: 30
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,23 @@ Do not document features that do not exist.
- prefer minor bumps for meaningful features or operational behavior changes
- prefer patch bumps for fixes, polish, and small compatibility updates
- reserve major bumps for explicit breaking changes and documented migrations

## Content boundary

Release notes, changelogs, PR descriptions, and commit messages **must
not** reference non-faigate topics — personal tooling, local setup
details, operator-machine specifics, or unrelated projects. The
prerelease workflow (`.github/workflows/prerelease.yml`) filters these
automatically using `.github/scripts/filter-changelog.py`, but agents
and humans should keep the source clean so the filter is a safety net,
not a load-bearing rewrite step.

Concrete examples that don't belong in faigate-public surfaces:

- personal memory or env-management tools running on the operator's box
- absolute paths under `/Users/<name>/`, `~/Library/`, `~/Documents/`
- operator-specific machine setup steps unrelated to faigate runtime

When such context is genuinely relevant to a change, keep it in the PR
conversation or in `docs/process/` — never in user-visible commit
messages, release titles, or `CHANGELOG.md` entries.
Loading