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.0",
"version": "1.23.1",
"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
12 changes: 8 additions & 4 deletions .claude/skills/promote/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ description: Forge-only — open a dev→main promotion PR when a MINOR or MAJOR

# Promote dev → main (forge-only)

> **Spec:** [`docs/release-process.md`](../../../docs/release-process.md) is the
> source of truth for the versioning + promotion model and its invariant→test
> contract. This skill is the *operational* runbook for that model.

Opens a `dev → main` promotion PR after a MINOR (`Y+1, Z→0`) or
MAJOR (`X+1, Y→0, Z→0`) bump to `.claude-plugin/plugin.json` lands on
`dev`. PATCH-only bumps (`Z+1`) do NOT trigger promotion — `dev`
accumulates patches between minor releases per CLAUDE.md "plugin
manifest version is rolling-next."
accumulates patches between minor releases per
`docs/release-process.md` §1 (rolling-next).

**Scope: forge repo only.** Lives at
`.claude/skills/promote/SKILL.md` (project-local, not shipped via the
Expand Down Expand Up @@ -62,7 +66,7 @@ Promotion pending — promote these in order (2):

Skip entirely when it reports **"Up to date — nothing to promote"**
(`main`'s minor ≥ `dev`'s minor; patch differences accumulate on `dev`
between releases per CLAUDE.md "rolling-next"), or when a promotion PR
between releases per `docs/release-process.md` §1), or when a promotion PR
(base `main`) is already open (Step 2).

Set `$NEW` to the **first** (lowest) listed release and promote that one.
Expand Down Expand Up @@ -112,7 +116,7 @@ gh pr create --base main --head "release/v$NEW" \
$(git log --oneline origin/main..release/v$NEW)

## After merge
- [ ] Tag handling per CLAUDE.md \"Dual-track tag cadence\": \`/next\` (\`forge-next-prep --tag\`) already created \`v$NEW\` on the dev commit. Confirm the tag exists; follow that section for any main-side tagging.
- [ ] Tag handling per \`docs/release-process.md\` §2 (dual-track tag cadence): \`/next\` (\`forge-next-prep --tag\`) already created \`v$NEW\` on the dev commit. Confirm the tag exists; follow that section for any main-side tagging.
- [ ] If more minors remain behind, promote the next one (repeat from Step 1).
"
```
Expand Down
13 changes: 6 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
## Forge-specific rules

- **Version derivation**: pip package version comes from the latest git tag via setuptools-scm. There is no manual `version = "x.y.z"` in `pyproject.toml`. Release flow: `git tag vX.Y.Z && git push origin vX.Y.Z`.
- **Plugin manifest version is rolling-next**: `.claude-plugin/plugin.json["version"]` always names the version about to be released. `step_plugin_version` in `forge-precommit` enforces `plugin.json > latest_tag` on every commit (skipped on the release commit itself). Workflow:
1. On the release PR, set `plugin.json["version"]` to the version about to be tagged (e.g. `"1.1.2"`).
2. Merge the PR, then `git tag vX.Y.Z` at the merge commit. The guard skips that single commit (`HEAD` == tag commit).
3. Immediately after tagging, the next PR must bump `plugin.json` to `"X.Y.(Z+1)"` (or a higher minor/major) — otherwise commits fail the guard.
- **Dual-track tag cadence** (disambiguates "the merge commit" in step 2 above for forge's `base_branch=main` / `dev_branch=dev` model): **tag `dev` `vX.Y.Z` after *every* merge to dev.** This is the dev-channel release and is mandatory — `forge-next-prep --tag` (run by `/next` Phase 1) does it automatically when `plugin.json` is ahead of the latest tag, so **never drop `--tag`**. `main` is advanced and tagged **only at the minor-boundary promotion** (`/promote`), where the minor `vX.Y.0` is (re-)tagged on main's squash commit. Net: `@dev` consumers receive every version (patch + minor); `@main` receives minors only. **Skipping a promotion does NOT mean skipping the dev tag** — the per-merge dev tag and the dev→main promotion are independent steps. (Caveat: because promotion squashes, a minor re-tagged onto main's squash commit is not reachable from dev's history; that is expected — `@dev` resolves the tag on the dev commit, `@main` resolves it on the main commit.)
- **`/promote` skill (forge-only)**: lives at `.claude/skills/promote/SKILL.md` — project-local, **not** shipped via the forge plugin. After merging a PR to `dev` that bumped `plugin.json` past a minor boundary (MINOR or MAJOR), invoke `/promote` to open the `dev → main` promotion PR with a release-summary squash message. Forge-private because the dev/main two-branch model + rolling-next convention are specific to forge; consumer plugin authors may follow trunk-based, gitflow, or other release models.
- **Semver policy for the plugin bump** — when the next-PR bump in step 3 above lands, choose the increment deliberately, not reflexively. The plugin's public surface is: every CLI in `[project.scripts]`, every agent name + canonical Output shape under `agents/`, every Claude Code hook under `claude-hooks/`, every skill under `skills/`, every FOUNDATION rule a consumer can rely on.
- **Release process — versioning, tag cadence, and `dev → main` promotion** are specified in **[`docs/release-process.md`](docs/release-process.md)**, the single source of truth + the **invariant→test contract**. Do not restate its mechanics elsewhere; point here. Operational summary:
- `.claude-plugin/plugin.json["version"]` is **rolling-next** — always names the version about to be released; `step_plugin_version` enforces `plugin.json > latest_tag` on every commit (skipped when `HEAD` reproduces a tagged release's tree). After tagging `vX.Y.Z`, the next PR must bump it.
- Tag `dev` `vX.Y.Z` after **every** merge to dev (`forge-next-prep --tag`, run by `/next` — **never drop `--tag`**); tag `main` **only** at the minor-boundary promotion. `@dev` gets every version, `@main` minors only.
- Promote `dev → main` **one minor at a time** via a `release/vX.Y.Z` branch (never `--head dev`) — the **`/promote`** skill (`.claude/skills/promote/SKILL.md`, forge-only, not shipped via the plugin).
- **Every versioning/promotion invariant names an enforcing test in the spec.** Do NOT change that code without the matching spec test staying green — e.g. the guard skips on *any* tagged-release tree (not just the latest), required for staged catch-up, locked by `test_main_skips_when_head_reproduces_older_tag`. This contract exists because #43 silently broke that behavior with no test to catch it.
- **Semver policy for the plugin bump** — when the post-tag rolling-next bump PR lands (see [`docs/release-process.md`](docs/release-process.md) §1), choose the increment deliberately, not reflexively. The plugin's public surface is: every CLI in `[project.scripts]`, every agent name + canonical Output shape under `agents/`, every Claude Code hook under `claude-hooks/`, every skill under `skills/`, every FOUNDATION rule a consumer can rely on.
- **PATCH (Z+1)** — internal-only changes: bug fix in an existing CLI / agent / hook with no behavior change visible to consumers; refactor with identical externals; doc typo fix; non-blocking new audit check; CLAUDE.md edits that affect only forge contributors.
- **MINOR (Y+1, Z→0)** — additive, backward-compatible: new CLI, new agent, new hook, new skill, new mandatory rule in FOUNDATION or `_TEMPLATE.md` that does NOT break existing consumer artifacts (e.g. a new reporter-agent header that older agents simply lack — audit flags it but does not refuse a commit), new mode/flag on an existing CLI or agent, new pre-commit step that self-skips when not opted in.
- **MAJOR (X+1, Y→0, Z→0)** — breaking: CLI renamed / removed / argument-incompatible; agent renamed / removed / Output shape changed in a way that breaks existing parsers (e.g. `pr-manager`); hook semantics inverted (allow → block); FOUNDATION rule promoted from soft to blocking; manifest layout change. Any consumer upgrade that requires action beyond `forge-upgrade` is MAJOR.
Expand Down
1 change: 1 addition & 0 deletions REPO_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ Forge's own bootstrap tooling (not a consumer pattern):
- claude-code-plugin.md: optional Claude Code plugin install + extension
- cli-reference.md: generated CLI reference (`forge-gen-cli-reference`)
- configuration.md: complete `[tool.forge.*]` config reference + setup guide (written counterpart to `forge-config --list`)
- release-process.md: forge-only single source of truth for versioning + dev→main promotion + the invariant→test contract
- customizing-precommit.md: adding repo-specific steps to `.githooks/pre-commit`
- security.md: security policy and review documentation
- standalone-installers.md: per-installer reference for manual usage (sibling of `install-forge-bootstrap`)
Expand Down
70 changes: 70 additions & 0 deletions docs/release-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Release process (forge-only)

**This is the single source of truth for forge's versioning and
`dev → main` promotion.** It is the *spec*; the code conforms to it. Every
invariant below names the **test that enforces it** — the executable spec
that goes red if code drifts. If you change versioning or promotion code,
change this doc and its tests **first**, then make the code match.

> Forge-only. The dual-track (`dev`/`main`) model and rolling-next
> convention are specific to forge; consumer plugin authors may use
> trunk-based, gitflow, or another model. CLAUDE.md's release bullets and
> the `/promote` skill **point here** — they do not restate the mechanics
> (FOUNDATION §12, single source of truth).

---

## 1. Rolling-next versioning

`.claude-plugin/plugin.json["version"]` **always names the version about to
be released** — never the last-released version.

- The pre-commit step `plugin_version` (`verify-forge-plugin-version`)
enforces `plugin.json["version"] > latest tag` on every commit.
- After tagging `vX.Y.Z`, the next PR must bump `plugin.json` to the next
rolling-next version, or its commits fail the guard.

## 2. Dual-track tag cadence

- **`dev` is tagged `vX.Y.Z` after *every* merge to `dev`** — patch and
minor alike. `forge-next-prep --tag` (run by `/next`) does this when
`plugin.json` is ahead of the latest tag. **Never drop `--tag`.**
- **`main` is tagged only at the minor-boundary promotion** — the minor
`vX.Y.0` is (re-)tagged on main's squash commit.
- Net: `@dev` consumers receive every version; `@main` receives **minors
only**. Patches accumulate on `dev` and fold into the next minor.
- Because promotion squashes, a minor re-tagged on main's squash commit is
not reachable from `dev`'s history — expected. `@dev` resolves the tag on
the dev commit; `@main` on the main commit.

## 3. Promotion: staged catch-up

- **Never merge `dev` directly into `main`.** A promotion PR's head is
always a dedicated `release/vX.Y.Z` branch.
- **One minor at a time, ascending.** When `main` is several minors behind,
promote each minor as its own clean squash commit; never lump multiple
minors into one PR.
- The release branch's tree reproduces the target minor's release tree
(cut from `main`, bring the minor's tree, verify `git diff` is empty).
- Run via the `/promote` skill, which uses `forge-next-prep
--promotion-status` for the ordered pending list.

## 4. Invariants the code MUST satisfy → enforcing tests

This table is the anti-regression contract. **Do not change a behavior in
the left column without its test (right column) staying green.** A change
that violates an invariant must turn its test red — that is how a future
"alignment fix" is stopped from silently amputating a working behavior
(this is exactly how the #43 regression slipped through: it changed an
invariant that had no test).

| Invariant | Where | Enforcing test |
|---|---|---|
| Latest tag resolved **globally** (semver-max, branch-independent) — never ancestry-scoped `git describe`, so the guard and the auto-tagger agree in the dual-track case | `git_utils.latest_v_tag` | `tests/test_git_utils.py::test_latest_v_tag_returns_highest_sorted` |
| Rolling-next guard skips when HEAD's tree reproduces **ANY** `v*` tag (not only the latest) — required so a `release/vX.Y.Z` branch for a minor *below* the global-max tag still passes | `verify_plugin_version._is_release_commit` | `tests/test_verify_plugin_version.py::test_main_skips_when_head_reproduces_older_tag` |
| 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` |

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.
80 changes: 50 additions & 30 deletions src/forge/verify_plugin_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
- ``.claude-plugin/plugin.json`` does not exist (consumer repo without
a plugin manifest).
- The repo has no git tags yet (pre-release repo).
- ``HEAD`` is the release commit (the commit pointed to by the latest
tag). On that single commit, ``plugin.json`` may equal the tag.
- ``HEAD``'s tree reproduces any published ``v*`` release tag — so a
staged ``release/vX.Y.Z`` branch promoting an older minor still passes
even when its ``plugin.json`` sits below the global-max tag.

``forge-precommit`` shells out to this CLI; agents may invoke it
standalone to refresh just ``plugin_version.log``.
Expand Down Expand Up @@ -43,49 +44,68 @@
_parse_semver = parse_semver


def _is_release_commit(repo_root: Path, tag: str) -> bool:
"""Return True when ``HEAD`` carries the same file content as *tag*.
def _is_release_commit(repo_root: Path) -> bool:
"""Return True when ``HEAD``'s tree reproduces ANY published ``v*`` tag.

Compares the git **tree** SHA of ``HEAD`` against the tag's tree
SHA — not the commit SHA. Tree equality means the working
file-state is identical; commit identity is irrelevant. This is
the right semantic for the rolling-next guard: the rule "you must
bump plugin.json past the latest tag" only applies when the
commit actually changes file content. Three cases that should
skip and do:
Compares the git **tree** SHA of ``HEAD`` against the tree of every
``v*`` tag — not commit SHAs. Tree equality means the working
file-state reproduces an already-tagged release, so the rolling-next
rule ("bump plugin.json past the latest tag") must NOT fire.

1. The literal release commit (HEAD == tag commit). Same commit,
trivially same tree.
2. A ``-s ours``-style merge that absorbs another branch with
no file diff (e.g. dev → main promotion back-merges). The
merge commit has a new SHA + new parents but its tree equals
the pre-merge tree, which equals the tag's tree.
3. ``git commit --allow-empty`` or a revert that nets to zero
file change — same reasoning.
Checking **every** tag — not only the latest — is load-bearing for
the staged ``dev → main`` promotion (see the ``promote`` skill).
When ``main`` is two or more minors behind, a ``release/vX.Y.Z``
branch carries an *older* minor's tree, so its ``plugin.json`` sits
legitimately **below** the global-max tag; it is still a real release
commit and must pass the guard. A prior version compared HEAD only
against the *latest* tag, which made promoting any minor below the
global-max impossible — the release branch's tree never equals the
latest tag's tree (regression from the #43 ancestry→global tag
switch). **Do not narrow this back to a single tag** — the
``test_main_skips_when_head_reproduces_older_tag`` test locks it.

Cases that correctly skip:

1. The literal release commit (HEAD == a tag commit) — same tree.
2. A staged ``release/vX.Y.Z`` promotion branch reproducing an older
tag's tree (``plugin.json`` below the global-max tag).
3. A ``-s ours`` merge / empty commit / net-zero revert — tree
unchanged from a tagged release.

Args:
repo_root: Git repo root.
tag: Tag name (e.g. ``"v1.1.2"``).

Returns:
``True`` when ``HEAD``'s tree SHA equals *tag*'s tree SHA;
``False`` when either resolution fails or the trees differ.
``True`` when ``HEAD``'s tree SHA equals the tree of some ``v*``
tag; ``False`` when HEAD's tree resolves emptily or matches none.
"""
tag_tree = subprocess.run(
["git", "rev-parse", f"{tag}^{{tree}}"],
head_tree = subprocess.run(
["git", "rev-parse", "HEAD^{tree}"],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
head_tree = subprocess.run(
["git", "rev-parse", "HEAD^{tree}"],
if not head_tree:
return False
tags = subprocess.run(
["git", "tag", "--list", "v*"],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
return bool(tag_tree) and tag_tree == head_tree
).stdout.split()
for tag in tags:
tag_tree = subprocess.run(
["git", "rev-parse", f"{tag}^{{tree}}"],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
if tag_tree and tag_tree == head_tree:
return True
return False


def main() -> int:
Expand Down Expand Up @@ -122,8 +142,8 @@ def main() -> int:
logger.info("(no git tags yet — skipped)")
return 0

if _is_release_commit(repo_root, latest_tag):
logger.info("(HEAD is the %s release commit — skipped)", latest_tag)
if _is_release_commit(repo_root):
logger.info("(HEAD reproduces a published v* release tag — skipped)")
return 0

plugin_data = json.loads(plugin.read_text())
Expand Down
Loading
Loading