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
55 changes: 0 additions & 55 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions knowledge/operations/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prev-tag>`), format `* type(scope): description ([#N](URL)) by @author`.
- End with `**Full Changelog**: URL`.

## Package Names and Registries
Expand Down
189 changes: 189 additions & 0 deletions scripts/check_changelog.py
Original file line number Diff line number Diff line change
@@ -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())
103 changes: 103 additions & 0 deletions scripts/tests/test_check_changelog.py
Original file line number Diff line number Diff line change
@@ -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()