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
4 changes: 3 additions & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ The highest-signal failures — collected from real releases. Read these before
tag is the `git tag -a vX.Y.Z` in step 8.
- **Two CHANGELOGs, not one.** `CHANGELOG.md` is the CLI changelog; `plugins/arckit-claude/CHANGELOG.md`
is the plugin changelog. Both need an entry. They are human-authored — `generate-release-notes.sh`
only *previews* what changed, it does not write them.
only *previews* what changed, it does not write them. `bump-version.sh` then runs
`scripts/trim-plugin-changelog.py`, which keeps the plugin file under 64 KB by moving whole
older minor series to `docs/changelog/arckit-plugin-archive.md`; commit both files.
- **CLI and plugin versions are independent but bumped together.** `bump-version.sh` moves both to
the same number by design; don't try to skew them.
- **`push-extensions.sh` needs `GH_TOKEN`** and skips repos that don't yet exist on GitHub — a
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/lint-markdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ jobs:
- name: Contributor credit check (CHANGELOG credits vs contributors page)
run: python3 scripts/check-contributor-credits.py

- name: Plugin CHANGELOG stays small enough for the plugin directory to inspect
run: python3 scripts/trim-plugin-changelog.py --check

- name: llms.txt sync check (every command indexed, no dead links)
run: python3 scripts/check-llms-txt.py

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **The plugin's changelog now lists only recent releases.** `plugins/arckit-claude/CHANGELOG.md` had grown to 321 KB, too large for the Claude plugin directory's validator to read, which kept the core plugin on a policy hold. It now holds the releases since 6.13.0 (59 KB), with a link at the top to `docs/changelog/arckit-plugin-archive.md`, where every older entry is kept word for word. `bump-version.sh` moves each minor series to the archive as it ages out, keeping the file under 64 KB, and CI fails if it passes 128 KB.

## [6.18.0] — 2026-10-09

### Added
Expand Down
3 changes: 2 additions & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ After your PR is merged to `main`:
# 1. Ensure you're on main with latest changes
git checkout main && git pull

# 2. Edit CHANGELOGs manually
# 2. Edit CHANGELOGs manually (bump-version.sh then moves the oldest
# plugin releases to docs/changelog/arckit-plugin-archive.md)

# 3. Preview release notes (optional)
./scripts/generate-release-notes.sh
Expand Down
2,692 changes: 2,692 additions & 0 deletions docs/changelog/arckit-plugin-archive.md

Large diffs are not rendered by default.

2,693 changes: 6 additions & 2,687 deletions plugins/arckit-claude/CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions scripts/bump-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ echo " metadata.version: $(jq -r '.metadata.version' plugins/arckit-claude/.c
jq -r '.plugins[] | " \(.name): \(.version)"' plugins/arckit-claude/.claude-plugin/marketplace.json | sed 's/^/ /'
echo ""

# Keep the shipped plugin CHANGELOG small: the plugin directory could not
# inspect it at 319 KB. Older releases move to docs/changelog/.
echo "── Plugin CHANGELOG ──"
echo ""
python3 scripts/trim-plugin-changelog.py | sed 's/^/ /'
echo ""

# Lint check
echo "── Lint ──"
echo ""
Expand Down
7 changes: 6 additions & 1 deletion scripts/check-contributor-credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
CHANGELOGS = (ROOT / "CHANGELOG.md", ROOT / "plugins/arckit-claude/CHANGELOG.md")
CHANGELOGS = (
ROOT / "CHANGELOG.md",
ROOT / "plugins/arckit-claude/CHANGELOG.md",
# Older plugin releases, moved out by scripts/trim-plugin-changelog.py.
ROOT / "docs/changelog/arckit-plugin-archive.md",
)
CONTRIBUTORS = ROOT / "docs/contributors.html"

# A handle mention must not be preceded by a word character, or every
Expand Down
1 change: 1 addition & 0 deletions scripts/standardise-colon.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
EXCLUDE_PREFIXES = [
"CHANGELOG.md",
"plugins/arckit-claude/CHANGELOG.md",
"docs/changelog/", # older plugin releases moved out of the plugin CHANGELOG
"docs/articles/",
"docs/plans/", # planning artefacts; may quote the dot form verbatim
"docs/proposals/",
Expand Down
174 changes: 174 additions & 0 deletions scripts/trim-plugin-changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Keep the shipped plugin CHANGELOG small; move older releases to an archive.

`plugins/arckit-claude/CHANGELOG.md` ships inside the core plugin, and the
Claude plugin directory's validator could not inspect it at 319 KB: it was one
of the two findings behind the "Files or downloads the validator couldn't
inspect" policy hold on v6.17.3 and v6.17.5. (A 211 KB template in the same
plugin was read without complaint, so the limit sits somewhere between.)

This script keeps the newest releases in the shipped file and moves the rest,
newest first, to `docs/changelog/arckit-plugin-archive.md`, which
`push-extensions.sh` never ships. It cuts only between minor series, so a
6.12.x series moves as a whole, and it keeps whole series while the file stays
within TARGET_BYTES. `[Unreleased]` and the newest series always stay. A line
under the title links to the archive and names the oldest release kept.

Usage:
python3 scripts/trim-plugin-changelog.py # move old releases to the archive
python3 scripts/trim-plugin-changelog.py --check # exit 1 if the file is over LIMIT_BYTES

`bump-version.sh` runs the trim on every release, and CI runs `--check`.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PLUGIN_CHANGELOG = ROOT / "plugins/arckit-claude/CHANGELOG.md"
ARCHIVE = ROOT / "docs/changelog/arckit-plugin-archive.md"
ARCHIVE_URL = "https://github.com/tractorjuice/arc-kit/blob/main/docs/changelog/arckit-plugin-archive.md"

TARGET_BYTES = 64 * 1024 # the trim keeps whole minor series up to this size
LIMIT_BYTES = 128 * 1024 # --check fails above this

SECTION_RE = re.compile(r"^## \[([^\]]+)\]", re.MULTILINE)
VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.\d+$")
POINTER_PREFIX = "Releases before "

ARCHIVE_HEADER = """# Changelog archive — ArcKit Plugin

Older releases of the ArcKit Claude Code plugin, newest first. They moved here
from `plugins/arckit-claude/CHANGELOG.md` to keep the shipped file small;
`scripts/trim-plugin-changelog.py` moves each series as it ages out.

"""


def split(text: str) -> tuple[str, list[tuple[str, str]]]:
"""Return (preamble, [(version, section text), ...]) in file order."""
starts = [(m.start(), m.group(1)) for m in SECTION_RE.finditer(text)]
if not starts:
return text, []
sections = []
for i, (pos, version) in enumerate(starts):
end = starts[i + 1][0] if i + 1 < len(starts) else len(text)
sections.append((version, text[pos:end]))
return text[: starts[0][0]], sections


def series(version: str) -> tuple[int, int] | None:
m = VERSION_RE.match(version)
return (int(m.group(1)), int(m.group(2))) if m else None


def size(text: str) -> int:
return len(text.encode("utf-8"))


def without_pointer(preamble: str) -> str:
lines = [line for line in preamble.rstrip("\n").split("\n") if not line.startswith(POINTER_PREFIX)]
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n\n"


def with_pointer(preamble: str, oldest_kept: str) -> str:
"""Add or refresh the line that links to the archive."""
pointer = (
f"{POINTER_PREFIX}{oldest_kept} are in the "
f"[changelog archive]({ARCHIVE_URL})."
)
return without_pointer(preamble) + pointer + "\n\n"


def trim(plugin_text: str, archive_text: str | None) -> tuple[str, str | None, list[str]]:
"""Return (new plugin text, new archive text, versions moved)."""
preamble, sections = split(plugin_text)
keep: list[tuple[str, str]] = []
move: list[tuple[str, str]] = []
# Measure without any existing pointer line and reserve room for a fresh
# one, so a second run sees the same total and moves nothing.
total = size(without_pointer(preamble)) + 200
current: tuple[int, int] | None = None
series_seen = 0
for version, body in sections:
s = series(version)
if s is None: # [Unreleased] and anything unversioned stays
keep.append((version, body))
total += size(body)
continue
if move:
move.append((version, body))
continue
if s != current:
current = s
series_seen += 1
series_bytes = sum(size(b) for v, b in sections if series(v) == s)
if series_seen > 1 and total + series_bytes > TARGET_BYTES:
move.append((version, body))
continue
total += series_bytes
keep.append((version, body))

if not move:
return plugin_text, archive_text, []

if archive_text is None:
archive_text = ARCHIVE_HEADER
archive_preamble, archived = split(archive_text)
already = {v for v, _ in archived}
clash = [v for v, _ in move if v in already]
if clash:
raise SystemExit(f"ERROR: already in the archive: {', '.join(clash)}")

oldest_kept = [v for v, _ in keep if series(v)][-1]
new_plugin = with_pointer(preamble, oldest_kept) + "".join(b for _, b in keep)
new_plugin = new_plugin.rstrip("\n") + "\n"
moved_text = "".join(b if b.endswith("\n\n") else b.rstrip("\n") + "\n\n" for _, b in move)
new_archive = archive_preamble + moved_text + "".join(b for _, b in archived)
new_archive = new_archive.rstrip("\n") + "\n"
return new_plugin, new_archive, [v for v, _ in move]


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--check", action="store_true", help=f"exit 1 if the plugin CHANGELOG is over {LIMIT_BYTES // 1024} KB")
args = parser.parse_args()

plugin_text = PLUGIN_CHANGELOG.read_text(encoding="utf-8")
current = size(plugin_text)

if args.check:
if current > LIMIT_BYTES:
print(
f"ERROR: {PLUGIN_CHANGELOG.relative_to(ROOT)} is {current // 1024} KB, over the "
f"{LIMIT_BYTES // 1024} KB limit. Run: python3 scripts/trim-plugin-changelog.py",
file=sys.stderr,
)
return 1
print(f"Plugin CHANGELOG is {current // 1024} KB (limit {LIMIT_BYTES // 1024} KB).")
return 0

archive_text = ARCHIVE.read_text(encoding="utf-8") if ARCHIVE.is_file() else None
new_plugin, new_archive, moved = trim(plugin_text, archive_text)
if not moved:
print(f"Plugin CHANGELOG is {current // 1024} KB; nothing to move.")
return 0

PLUGIN_CHANGELOG.write_text(new_plugin, encoding="utf-8")
ARCHIVE.parent.mkdir(parents=True, exist_ok=True)
ARCHIVE.write_text(new_archive, encoding="utf-8")
print(
f"Moved {len(moved)} releases ({moved[0]} back to {moved[-1]}) to "
f"{ARCHIVE.relative_to(ROOT)}. Plugin CHANGELOG: {current // 1024} KB -> {size(new_plugin) // 1024} KB."
)
return 0


if __name__ == "__main__":
sys.exit(main())
104 changes: 104 additions & 0 deletions tests/plugin/test_trim_plugin_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for scripts/trim-plugin-changelog.py.

The plugin directory's validator could not inspect the shipped plugin CHANGELOG
at 319 KB. The script moves older releases to an archive that does not ship;
these tests pin that it never loses or reorders a release, cuts only between
minor series, and is safe to run on every release.
"""

from __future__ import annotations

import importlib.util
import subprocess
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT = ROOT / "scripts/trim-plugin-changelog.py"


def _load():
spec = importlib.util.spec_from_file_location("trim_plugin_changelog", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


trim = _load()

PREAMBLE = "# Changelog — ArcKit Plugin\n\nAll notable changes.\n\n"


def section(version: str, filler: int = 400) -> str:
return f"## [{version}] — 2026-01-01\n\n### Fixed\n\n- {'x' * filler}\n\n"


def changelog(*versions: str) -> str:
return PREAMBLE + "## [Unreleased]\n\n" + "".join(section(v) for v in versions)


@pytest.fixture
def small_target(monkeypatch):
# Room for the preamble, [Unreleased] and about three sections.
monkeypatch.setattr(trim, "TARGET_BYTES", 1700)


def versions(text: str) -> list[str]:
return [v for v, _ in trim.split(text)[1]]


def test_repo_is_currently_within_limit():
result = subprocess.run([sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True)
assert result.returncode == 0, result.stderr


def test_cuts_between_minor_series_and_loses_nothing(small_target):
original = changelog("6.17.1", "6.17.0", "6.16.2", "6.16.1", "6.16.0", "6.15.0")
plugin, archive, moved = trim.trim(original, None)

# 6.17.x fits, the whole 6.16.x series does not, so it moves with everything older.
assert versions(plugin) == ["Unreleased", "6.17.1", "6.17.0"]
assert moved == ["6.16.2", "6.16.1", "6.16.0", "6.15.0"]
assert versions(archive) == moved
assert versions(plugin)[1:] + versions(archive) == versions(original)[1:]


def test_newest_series_always_stays(monkeypatch):
monkeypatch.setattr(trim, "TARGET_BYTES", 10)
plugin, _, moved = trim.trim(changelog("6.17.1", "6.17.0", "6.16.0"), None)
assert versions(plugin) == ["Unreleased", "6.17.1", "6.17.0"]
assert moved == ["6.16.0"]


def test_second_run_moves_nothing(small_target):
plugin, archive, _ = trim.trim(changelog("6.17.0", "6.16.0", "6.15.0", "6.14.0"), None)
again, archive_again, moved = trim.trim(plugin, archive)
assert moved == []
assert again == plugin and archive_again == archive


def test_later_moves_go_on_top_of_the_archive(small_target):
plugin, archive, _ = trim.trim(changelog("6.17.0", "6.16.0", "6.15.0", "6.14.0"), None)
# Three new releases push the next series out.
grown = plugin.replace("## [Unreleased]\n\n", "## [Unreleased]\n\n" + section("6.18.1") + section("6.18.0") + section("6.17.9"))
plugin2, archive2, moved = trim.trim(grown, archive)
assert moved
assert versions(archive2) == moved + versions(archive)


def test_pointer_names_oldest_kept_release_once(small_target):
plugin, archive, _ = trim.trim(changelog("6.17.0", "6.16.0", "6.15.0", "6.14.0"), None)
grown = plugin.replace("## [Unreleased]\n\n", "## [Unreleased]\n\n" + section("6.18.1") + section("6.18.0") + section("6.17.9"))
plugin2, _, _ = trim.trim(grown, archive)
pointers = [line for line in plugin2.splitlines() if line.startswith(trim.POINTER_PREFIX)]
assert len(pointers) == 1
assert pointers[0].startswith(f"{trim.POINTER_PREFIX}{versions(plugin2)[-1]} ")
assert trim.ARCHIVE_URL in pointers[0]


def test_refuses_to_archive_a_release_twice(small_target):
_, archive, _ = trim.trim(changelog("6.17.0", "6.16.0", "6.15.0", "6.14.0"), None)
with pytest.raises(SystemExit):
trim.trim(changelog("6.17.0", "6.16.0", "6.15.0", "6.14.0"), archive)
Loading