diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e995ce2..3e6394603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,6 @@ ### Highlights -- **Faster ported coreutils builtins.** Ported coreutils builtins reuse pre-built clap - `Command` definitions instead of rebuilding them per invocation - ([#2347](https://github.com/everruns/bashkit/pull/2347)). - **Security hardening.** The Rust update takes `russh` 0.63.1 security fixes ([#2365](https://github.com/everruns/bashkit/pull/2365)), `sqlite` budgets account for work performed inside a single engine step (TM-SQL-014) @@ -19,58 +16,6 @@ embeddings to parity with other host bindings ([#2371](https://github.com/everruns/bashkit/pull/2371)). -### Added - -- Bindings now support read-only filesystem mounts, so hosts can share directories with - sandboxed scripts without granting write access - ([#2393](https://github.com/everruns/bashkit/pull/2393)) -- The C ABI exposes host-directory mounts, bringing native embeddings to parity with - other host bindings - ([#2371](https://github.com/everruns/bashkit/pull/2371)) - -### Fixed - -- Host calls enforce execution deadlines while parked - ([#2349](https://github.com/everruns/bashkit/pull/2349)). -- Runtime mounts use canonical replay keys across binding rebuilds - ([#2366](https://github.com/everruns/bashkit/pull/2366)). -- SQLite budgets account for work within a single engine step - ([#2369](https://github.com/everruns/bashkit/pull/2369)). -- Rust dependency update takes `russh` 0.63.1 security fixes - ([#2365](https://github.com/everruns/bashkit/pull/2365)). -- Documentation preserves inline SVG diagrams during Markdown rendering - ([#2368](https://github.com/everruns/bashkit/pull/2368)). -- The `awk` number lexer no longer swallows a following `+`/`-` operator - ([#2392](https://github.com/everruns/bashkit/pull/2392)). -- `RealFs` sets mtime with `FILE_WRITE_ATTRIBUTES` on Windows so read-only files keep - updatable timestamps - ([#2391](https://github.com/everruns/bashkit/pull/2391)). -- Synced `uutils` coreutils drift and closed the host-environment hole in builtins - ([#2385](https://github.com/everruns/bashkit/pull/2385)). -- CI's aggregate check includes WASM validation, and secret-backed examples - fetch scoped API keys in separate steps that end before repository code runs. -- Browser persistence preserves the previous save when directory traversal fails. -- BashTool snapshot constructors retain supplied JavaScript custom builtins. -- Release binary builds use the exact validated commit; Cargo verification runs - without registry credentials, and JS release examples use reviewed lockfiles. -- CI drops unused write permissions and installs SQLite only when missing. -- Deep Agents supports the current structured backend protocol, exact VFS file - transfers, grep glob filters, and execution truncation metadata. -- Anthropic sanitized tool output is capped after XML escaping, preserving - complete entities and matching the OpenAI adapter. - -### Changed - -- Refresh Rust, JavaScript, site, and example dependencies; update DeepSec to - 2.3.9. Pin Monty to 0.0.19 and get-size2 to 0.10.1 to preserve per-VM - memory, work, and cancellation limits. -- Maintenance requests now run analysis, fixes, validation, and shipping through - green CI and merge, including common misspellings. -- Refresh benchmark baselines; select a supported Bash from `PATH` and record - its version instead of silently using macOS's obsolete system shell. -- README lists Java and Elixir community bindings - ([#2390](https://github.com/everruns/bashkit/pull/2390)). - ### What's Changed * fix(host-call): enforce the execution deadline while parked by @chaliy in #2349 diff --git a/justfile b/justfile index 3b6458401..3da924f90 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,11 @@ check: just check-okf just check-doc-links just check-workflow-parity + just check-changelog + +# Validate the CHANGELOG release-section convention (Highlights + What's Changed). +check-changelog: + python3 scripts/check_changelog.py # Validate the canonical public-surface capability matrix and generated inventory. check-capability-parity: diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index c36cba329..b295bf4c6 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -173,9 +173,9 @@ v0.4.0 → v0.4.1 for a worked example). Use the latest entries in `CHANGELOG.md` as the template. Rules: - `## [X.Y.Z] - YYYY-MM-DD` header. -- `### Highlights`, 2-5 most impactful, user-facing bullets. +- `### Highlights`, 2-5 most impactful, user-facing bullets, each with its PR link. Validate with `just check-changelog`. - `### Breaking Changes` for MINOR/MAJOR with bold summary + before/after migration guide. -- `### What's Changed` (not separate Added/Changed/Fixed), PRs in descending PR-number order, format `* type(scope): description ([#N](URL)) by @author`. +- `### What's Changed` (not separate Added/Changed/Fixed), PRs in merge order (as GitHub generates them; scaffold with `python3 scripts/check_changelog.py --scaffold `), format `* type(scope): description ([#N](URL)) by @author`. - End with `**Full Changelog**: URL`. ## Package Names and Registries diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 000000000..d7656d382 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,189 @@ +"""Deterministic CHANGELOG release-section checks. + +Lock the v0.18.0 release-page drift: a stale `#2347` highlight carried over +from v0.17.1, a verbatim-duplicated C ABI bullet (Highlights + Added), and +ten curated bullets with no PR link (the #2378 maintenance scope). + +Convention C: the release section holds curated `### Highlights` plus an +exhaustive `### What's Changed` -- no Added/Fixed/Changed subsections (their +entries duplicate What's Changed by construction). + +Rules (top `## [...]` section only): + R0 every PR link is well-formed: `[#N](.../pull/N)` with matching numbers. + R1 every Highlights bullet carries a PR link. + R2 no two Highlights bullets share normalized text (links stripped, case and + whitespace folded, substring containment included). + R3 no curated PR link may already appear in an older `## [x.y.z]` section + (stale carry-over from a previous release). + R4 no subsection other than Highlights / What's Changed may exist. + +`--scaffold PREV_TAG` prints a deterministic `## What's Changed` block built +from `git log PREV_TAG..HEAD` merge order (same order GitHub generates). +""" +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = ROOT / "CHANGELOG.md" +PULL_URL = "https://github.com/everruns/bashkit/pull/" + +SECTION_RE = re.compile(r"^## \[([^\]]+)\]") +SUBSECTION_RE = re.compile(r"^### (.+?)\s*$") +LINK_RE = re.compile(r"\[#(\d+)\]\(https://github\.com/everruns/bashkit/pull/(\d+)\)") +PR_SUFFIX_RE = re.compile(r"^(.*)\s+\(#(\d+)\)\s*$") + + +def parse_sections(text): + """Split markdown into [(title, lines)] at `## [...]` headers.""" + sections, title, buf = [], None, [] + for line in text.splitlines(): + m = SECTION_RE.match(line) + if m: + if title is not None: + sections.append((title, buf)) + title, buf = m.group(1), [] + elif title is not None: + buf.append(line) + if title is not None: + sections.append((title, buf)) + return sections + + +def iter_bullets(lines): + """Yield (subsection, text) joining wrapped continuation lines.""" + sub, cur = "top", None + for line in lines + [""]: + m = SUBSECTION_RE.match(line) + if m: + if cur is not None: + yield sub, " ".join(cur) + cur = None + sub = m.group(1) + continue + if line.startswith("- "): + if cur is not None: + yield sub, " ".join(cur) + cur = [line[2:]] + elif cur is not None and line.startswith(" ") and line.strip(): + cur.append(line.strip()) + elif cur is not None and not line.strip(): + yield sub, " ".join(cur) + cur = None + if cur is not None: + yield sub, " ".join(cur) + + +LINK_SENTENCE_RE = re.compile( + r"\(\[#\d+\]\(https://github\.com/everruns/bashkit/pull/\d+\)\)\.?" +) + + +def normalize(text): + text = LINK_SENTENCE_RE.sub("", text) # link (+ trailing period) is not prose + return re.sub(r"\s+", " ", text).strip().lower() + + +def check_section(title, lines, older_prs): + """Return list of violation strings for one section.""" + errors = [] + seen = {} + for sub, bullet in iter_bullets(lines): + if sub == "What's Changed": + continue + links = LINK_RE.findall(bullet) + for text_n, url_n in links: + if text_n != url_n: + errors.append(f"[{title}] malformed link (text #{text_n} != url #{url_n}): {bullet[:80]}") + if not links: + errors.append(f"[{title}] curated bullet without PR link ({sub}): {bullet[:80]}") + continue + norm = normalize(bullet) + if norm in seen: + errors.append(f"[{title}] duplicate bullet text ({sub}, first in {seen[norm]}): {bullet[:80]}") + else: + dup = next((prev for prev in seen + if len(norm) >= 30 and len(prev) >= 30 + and (norm in prev or prev in norm)), None) + if dup is not None: + errors.append(f"[{title}] duplicate bullet text ({sub}, first in {seen[dup]}): {bullet[:80]}") + else: + seen[norm] = sub + for text_n, _ in links: + if text_n in older_prs: + errors.append(f"[{title}] stale PR #{text_n} already released in {older_prs[text_n]}: {bullet[:80]}") + return errors + + +def collect_prs(lines): + """Map PR number -> True for every PR link in section lines.""" + prs = {} + for _, bullet in iter_bullets(lines): + for text_n, _ in LINK_RE.findall(bullet): + prs[text_n] = True + return prs + + +def check_changelog(text): + sections = parse_sections(text) + if not sections: + return ["no ## [version] sections found"] + errors = [] + older_prs = {} + for title, lines in reversed(sections[1:]): + if re.fullmatch(r"\d+\.\d+\.\d+", title): + for n in collect_prs(lines): + older_prs.setdefault(n, title) + title, lines = sections[0] + subs = [m.group(1) for line in lines if (m := SUBSECTION_RE.match(line))] + for sub in subs: + if sub not in ("Highlights", "What's Changed"): + errors.append(f"[{title}] unexpected subsection '### {sub}' (use Highlights + What's Changed only)") + errors.extend(check_section(title, lines, older_prs)) + return errors + + +def build_whats_changed(subjects): + """Pure: subjects -> What's Changed lines, merge order kept, PR-less dropped.""" + lines = ["## What's Changed", ""] + seen = set() + for subject in subjects: + m = PR_SUFFIX_RE.match(subject) + if not m: + continue # direct push, no PR: GitHub omits it too + title, num = m.group(1).strip(), m.group(2) + if num in seen: + continue + seen.add(num) + lines.append(f"* {title} in [#{num}]({PULL_URL}{num})") + return lines + + +def scaffold(prev_tag, end="HEAD"): + out = subprocess.run( + ["git", "-C", str(ROOT), "log", f"{prev_tag}..{end}", "--pretty=format:%s"], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + print("\n".join(build_whats_changed(out))) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="CHANGELOG release-section checks") + ap.add_argument("--scaffold", metavar="PREV_TAG", + help="print deterministic What's Changed block since tag") + ap.add_argument("--end", default="HEAD", help="range end for --scaffold") + ap.add_argument("path", nargs="?", default=str(CHANGELOG)) + args = ap.parse_args(argv) + if args.scaffold: + scaffold(args.scaffold, args.end) + return 0 + errors = check_changelog(Path(args.path).read_text()) + for e in errors: + print(f"check_changelog: {e}", file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_changelog.py b/scripts/tests/test_check_changelog.py new file mode 100644 index 000000000..5cf7eebaa --- /dev/null +++ b/scripts/tests/test_check_changelog.py @@ -0,0 +1,103 @@ +"""Tests for scripts/check_changelog.py (convention C: Highlights + What's Changed).""" +import importlib.util +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location( + "check_changelog", Path(__file__).resolve().parents[1] / "check_changelog.py") +mod = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mod) + +CLEAN = """\ +# Changelog + +## [Unreleased] + +### Highlights + +- **Security hardening.** Request sanitization + ([#100](https://github.com/everruns/bashkit/pull/100)). +- **Faster builds.** Cached definitions reuse pre-built command objects on every run + ([#101](https://github.com/everruns/bashkit/pull/101)). + +### What's Changed + +* something by @user in [#100](https://github.com/everruns/bashkit/pull/100) +""" + + +class CheckChangelogTests(unittest.TestCase): + def test_clean_section_passes(self): + self.assertEqual(mod.check_changelog(CLEAN), []) + + def test_orphan_bullet_fails(self): + text = CLEAN.replace( + "- **Faster builds.** Cached definitions reuse pre-built command objects on every run\n" + " ([#101](https://github.com/everruns/bashkit/pull/101)).", + "- **Faster builds.** Cached definitions with no link.", + ) + errs = mod.check_changelog(text) + self.assertTrue(any("without PR link" in e for e in errs), errs) + + def test_verbatim_duplicate_fails(self): + text = CLEAN.replace( + "### What's Changed", + "- **Faster builds.** Cached definitions reuse pre-built command objects on every run\n" + " ([#101](https://github.com/everruns/bashkit/pull/101)).\n\n### What's Changed", + ) + errs = mod.check_changelog(text) + self.assertTrue(any("duplicate bullet text" in e for e in errs), errs) + + def test_containment_duplicate_fails(self): + text = CLEAN.replace( + "### What's Changed", + "- Cached definitions reuse pre-built command objects\n" + " ([#101](https://github.com/everruns/bashkit/pull/101)).\n\n### What's Changed", + ) + # new bullet's text is contained in the Faster builds bullet -> duplicate + errs = mod.check_changelog(text) + self.assertTrue(any("duplicate bullet text" in e for e in errs), errs) + + def test_stale_pr_link_fails(self): + text = CLEAN + "\n## [1.0.0] - 2026-01-01\n\n### Highlights\n\n" + \ + "- **Old work.** Did this before\n" + \ + " ([#100](https://github.com/everruns/bashkit/pull/100)).\n" + errs = mod.check_changelog(text) + self.assertTrue(any("stale PR #100" in e for e in errs), errs) + + def test_extra_subsection_fails(self): + text = CLEAN.replace( + "### What's Changed", + "### Fixed\n\n- A fix with link\n" + " ([#102](https://github.com/everruns/bashkit/pull/102)).\n\n### What's Changed", + ) + errs = mod.check_changelog(text) + self.assertTrue(any("unexpected subsection" in e for e in errs), errs) + + def test_malformed_link_fails(self): + text = CLEAN.replace( + "[#100](https://github.com/everruns/bashkit/pull/100)", + "[#100](https://github.com/everruns/bashkit/pull/999)", + ) + errs = mod.check_changelog(text) + self.assertTrue(any("malformed link" in e for e in errs), errs) + + def test_whats_changed_exempt_from_link_rule(self): + text = CLEAN.replace( + "* something by @user in [#100](https://github.com/everruns/bashkit/pull/100)", + "* something with no link at all", + ) + self.assertEqual(mod.check_changelog(text), []) + + def test_scaffold_preserves_merge_order_drops_pr_less(self): + subjects = ["feat: b (#12)", "fix: a (#3)", "chore: no pr here", "fix: a (#3)"] + lines = mod.build_whats_changed(subjects) + self.assertEqual( + [ln for ln in lines if ln.startswith("* ")], + ["* feat: b in [#12](https://github.com/everruns/bashkit/pull/12)", + "* fix: a in [#3](https://github.com/everruns/bashkit/pull/3)"], + ) + + +if __name__ == "__main__": + unittest.main()