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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "1.23.1",
"version": "1.23.2",
"description": "Automate Python CI/CD and code-quality standards — deterministic CLIs + a drop-in pre-commit hook, runnable with or without an AI agent. This optional Claude Code plugin orchestrates them; the gate is the CLI, never the model.",
"author": {
"name": "Jean Simonnet",
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,41 @@ change groups by conventional-commit type (**Features / Fixes / Refactor
Follows [Keep a Changelog](https://keepachangelog.com/) in spirit;
versions follow forge's rolling-next convention.

## v1.23.0 — 2026-06-17

### Features
- `forge-config --list` advisor + repo-wide `[tool.forge].source_dirs` /
`test_dirs` layout keys + `docs/configuration.md`; `[tool.interrogate]`
stays native (no wrapper).
- New `/forge:test` skill chaining the test agents (advisor → writer →
review → precommit-fixer).

### Fixes
- Rolling-next version guard now skips when `HEAD`'s tree reproduces
**any** published `v*` tag (not only the latest), unblocking staged
promotion of a minor that sits below the global-max tag.

### Docs
- `docs/release-process.md` — single source of truth for versioning,
`dev → main` promotion, and the invariant→test contract.

## v1.22.0 — 2026-06-17

### ⚠️ Upgrade notes
- **`block_protected_branches` now also protects `dev` by default.**
Direct pushes to `[tool.forge].dev_branch` (default `dev`) are blocked
for agents — open a PR instead. Single-track repos are unaffected
(`dev_branch` defaults to the base branch).

### Fixes
- `forge-next-prep --promotion-status` lists pending **minors only**
(`X.Y.0`); interleaved patch tags fold into the next minor.

### Refactor / Tooling
- The version guard and the auto-tagger now resolve "latest release" the
same way (global semver-max `v*` tag), fixing dual-track disagreement
where a tag on `main` is absent from `dev`'s history.

## v1.21.0 — 2026-06-12

### Features
Expand Down
28 changes: 28 additions & 0 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,34 @@ invariant that had no test).
| Guard fails when a real content change leaves `plugin.json ≤ latest tag` | `verify_plugin_version.main` | `tests/test_verify_plugin_version.py::test_fail_when_version_not_strictly_greater` |
| `--promotion-status` lists pending **minors only** (`X.Y.0`); interleaved patch tags fold into the next minor | `next_prep._promotion_status_lines` | `tests/test_next_prep.py::test_promotion_status_excludes_patch_tags` |
| `forge-next-prep --tag` tags + pushes only when `plugin.json` is strictly newer than the latest tag (idempotent) | `next_prep._maybe_tag_release` | `tests/test_next_prep.py::test_maybe_tag_release_creates_and_pushes_new_tag` |
| `--promotion-status` flags a pending minor that has no `## vX.Y.0` entry in `origin/<dev>`'s `CHANGELOG.md` (non-blocking advisory; silent when the repo keeps no CHANGELOG) | `next_prep._promotion_status_lines` | `tests/test_next_prep.py::test_promotion_status_flags_missing_changelog_entry` |

When you add a versioning/promotion behavior, add a row here **and** its
test. When you find an invariant with no test, that gap is a bug to close.

## 5. CHANGELOG at release

`CHANGELOG.md` records **one entry per promoted minor** (`vX.Y.0`) — the
slow channel ships minors only, so patches do **not** get their own
entry; they fold into the next minor's entry when it promotes.

**Each entry is authored on `dev`, before the promotion** — written as
the minor is finalized (a small docs PR on `dev`, or folded into the
last feature PR of that minor). The promotion branch is cut from `dev`'s
tree, so it carries the entry onto `main` automatically when it merges.

- **`dev` is the single source.** Never hotfix a CHANGELOG entry directly
onto `main` after promoting, and never back-merge `main → dev` to
reconcile it — that makes `main` a second source and adds two PRs per
release. The entry flows one way: authored on `dev`, carried to `main`
by the release branch.
- **Enforcement is a non-blocking advisory.** `forge-next-prep
--promotion-status` (run by `/promote`) appends a `⚠️` line when a
pending minor has no `## vX.Y.0` heading in `origin/<dev>`'s
`CHANGELOG.md`. It is advisory, not a gate: it never changes the exit
code, and it stays silent for repos that keep no `CHANGELOG.md`. A
blocking variant would be a new gate (MINOR) — tracked separately.
- **One-time catch-up exception.** When `main` has *already* shipped a
minor whose entry was never written (a historical gap), repair it with
a one-off patch hotfix to `main` plus a back-merge to `dev`. This is a
repair, not the steady-state flow above.
36 changes: 36 additions & 0 deletions src/forge/next_prep.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,28 @@ def _check_promote_pending_message(
)


def _changelog_lacks_entry(changelog_text: str, minor_tag: str) -> bool:
"""Return True when *changelog_text* has no ``## <minor_tag>`` heading.

Matches a heading whose version token equals ``minor_tag`` — either
``## v1.6.0 — <date>`` (the Keep-a-Changelog form forge uses) or a
bare ``## v1.6.0`` — so the optional date suffix does not defeat the
lookup. Drives the non-blocking promotion advisory; see
``docs/release-process.md`` §5.

Args:
changelog_text: Full ``CHANGELOG.md`` contents.
minor_tag: Release tag to look for, e.g. ``"v1.6.0"``.

Returns:
``True`` when no heading for ``minor_tag`` is present.
"""
return not any(
line.startswith(f"## {minor_tag} ") or line.strip() == f"## {minor_tag}"
for line in changelog_text.splitlines()
)


def _promotion_status_lines(
repo_root: Path,
dev_branch: str,
Expand Down Expand Up @@ -199,6 +221,20 @@ def _promotion_status_lines(
return lines
lines.append(f"Promotion pending — promote these in order ({len(staged)}):")
lines.extend(f" {tag}" for _, tag in staged)
# Non-blocking CHANGELOG advisory (docs/release-process.md §5): each
# promoted minor should already carry its entry, authored on dev.
# Stays silent for repos that keep no CHANGELOG (git show → empty).
changelog = _git(
"show", f"origin/{dev_branch}:CHANGELOG.md", cwd=repo_root, check=False
)
if changelog:
missing = [tag for _, tag in staged if _changelog_lacks_entry(changelog, tag)]
if missing:
lines.append(
f"⚠️ CHANGELOG.md (origin/{dev_branch}) has no entry for "
f"{', '.join(missing)} — author it on {dev_branch} before "
"promoting (docs/release-process.md §5)."
)
return lines


Expand Down
55 changes: 55 additions & 0 deletions tests/test_next_prep.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,61 @@ def test_promotion_status_excludes_patch_tags(
assert pending == ["v1.20.0", "v1.21.0"]


def test_promotion_status_flags_missing_changelog_entry(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A pending minor with no CHANGELOG entry gets a non-blocking advisory.

Locks docs/release-process.md §5: each promoted minor's entry is
authored on dev; ``--promotion-status`` flags any that are missing
without changing the exit code. Here v1.21.0 has an entry, v1.20.0
does not → only v1.20.0 is flagged.
"""

def _fake_git(*args: str, **_kw: object) -> str:
if args[:1] == ("tag",):
return "v1.19.0 v1.20.0 v1.21.0"
if args[:1] == ("show",):
return "## v1.21.0 — 2026-06-17\n\n### Features\n- thing\n"
return ""

monkeypatch.setattr(
next_prep,
"_read_plugin_version_at_ref",
lambda _root, ref: "1.21.0" if "dev" in ref else "1.19.0",
)
monkeypatch.setattr(next_prep, "_git", _fake_git)
lines = next_prep._promotion_status_lines(tmp_path, "dev", "main")
advisory = [line for line in lines if "no entry" in line]
assert len(advisory) == 1
assert "v1.20.0" in advisory[0]
assert "v1.21.0" not in advisory[0]


def test_promotion_status_silent_when_changelog_complete(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No advisory when every pending minor already has a CHANGELOG entry."""

def _fake_git(*args: str, **_kw: object) -> str:
if args[:1] == ("tag",):
return "v1.19.0 v1.20.0 v1.21.0"
if args[:1] == ("show",):
return "## v1.21.0 — 2026-06-17\n\n## v1.20.0 — 2026-06-17\n"
return ""

monkeypatch.setattr(
next_prep,
"_read_plugin_version_at_ref",
lambda _root, ref: "1.21.0" if "dev" in ref else "1.19.0",
)
monkeypatch.setattr(next_prep, "_git", _fake_git)
lines = next_prep._promotion_status_lines(tmp_path, "dev", "main")
assert not [line for line in lines if "no entry" in line]


def test_promotion_status_includes_major_release(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading