From 730e6b7371a6ec22b27b510801c63ff1272e8250 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:57:52 +0000 Subject: [PATCH] release: `just release X.Y.Z` and release.yml One command, run twice around a human merge. On a main whose versions are not X.Y.Z it writes the version into plugin.json, flake.nix and every documented pin, adds a CHANGELOG heading with the commits since the last tag, and pushes chore/release-vX.Y.Z for a PR. On a main that already says X.Y.Z everywhere it tags vX.Y.Z and pushes the tag; release.yml checks the versions against the tag and publishes the GitHub release. `just quality` now fails when the versions disagree. The consumer pin-bump PRs from #47 wait on the token decision there. Part of #47. Tested: release.py --self-test; --check on main (29 locations at 0.6.0) and against 0.5.1 (fails); --dry-run of both paths in a fresh clone; ruff, actionlint, tests/self-tests.sh, docs-lint, agents-check; trial merge with #32 and #33 passes the same gates Cost: n/a (no measured figure in this cloud session) Co-Authored-By: Claude --- .github/workflows/release.yml | 24 ++++ docs/3-development.md | 27 +++-- justfile | 6 + scripts/release.py | 200 ++++++++++++++++++++++++++++++++++ tests/self-tests.sh | 2 +- 5 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98fdbb0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +# A pushed v* tag (`just release X.Y.Z`, run on main once the release PR merged) becomes a +# published GitHub release. A tag whose versions disagree with it gets none. +name: release + +on: + push: + tags: ["v*"] + +permissions: {} + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - run: python3 scripts/release.py --check "$GITHUB_REF_NAME" + - env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes --title "marola-devkit $GITHUB_REF_NAME" diff --git a/docs/3-development.md b/docs/3-development.md index 5a5b106..db8aacb 100644 --- a/docs/3-development.md +++ b/docs/3-development.md @@ -44,17 +44,22 @@ at the real repo, and `git -C config` once rewrote this repo's `.git/confi ## Releases -A change that alters behaviour for consumers is a release. These move together in the PR: - -- `plugins/marola-devkit/.claude-plugin/plugin.json`'s `version`: Claude Code uses it to decide - when an installed copy is stale; -- `flake.nix`'s package `version`; -- every documented pin in `README.md` and `docs/` (`github:marola-dev/marola-devkit/v…`, each - workflow `@v…` and `devkit-ref`, the marketplace `ref`); -- a `CHANGELOG.md` entry, newest first. - -A human tags `vX.Y.Z` on `main` after the merge. Consumers adopt it by bumping their flake input, -workflow `@v…`/`devkit-ref` and marketplace `ref` in one PR each. +A change that alters behaviour for consumers is a release. Its version lives in +`plugins/marola-devkit/.claude-plugin/plugin.json` (Claude Code uses it to decide when an +installed copy is stale), `flake.nix`'s package version and every documented pin in `README.md`, +`docs/` and the workflow comments; `just quality` fails when they disagree +(`scripts/release.py --check`, at [scripts/release.py](https://github.com/marola-dev/marola-devkit/blob/main/scripts/release.py)). + +1. On an up-to-date `main`, `just release X.Y.Z` writes the version everywhere, adds a + `CHANGELOG.md` heading listing the commits since the last tag, and pushes + `chore/release-vX.Y.Z`. Turn the list into prose and open the PR with `just pr`. +2. After it merges, `just release X.Y.Z` again on `main`: the versions now match, so it tags + `vX.Y.Z` and pushes the tag. `release.yml` checks the versions against the tag and publishes + the GitHub release. + +`--dry-run` prints what either step would do. When a feature PR already moved the versions, +step 1 is skipped. Consumers adopt a release by bumping their flake input, workflow +`@v…`/`devkit-ref` and marketplace `ref` in one PR each. ## Secrets and cost diff --git a/justfile b/justfile index a129d5e..d8f87d6 100644 --- a/justfile +++ b/justfile @@ -23,8 +23,14 @@ quality: scripts/agents-check.sh python3 scripts/docs_lint.py python3 scripts/skills_vendor.py check --lock plugins/marola-devkit/skills/skills.lock + python3 scripts/release.py --check if command -v claude >/dev/null; then claude plugin validate .; else echo "claude not on PATH — skipping plugin validate"; fi +# Release the devkit: a version-bump branch for the release PR, or, once main carries X.Y.Z, its tag. +# just release 0.7.0 [--dry-run] +release *args: + python3 scripts/release.py {{ args }} + # The git hooks' contract (README): fast checks at commit, the full gate at push. precommit: ruff check . diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..86f71a9 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Release the devkit: `just release X.Y.Z [--dry-run]`. + +Two runs of the same command, with a human merge between them: +- versions on main are not X.Y.Z yet: write X.Y.Z into every location (`plugin.json`, the flake + package, the documented pins) and a CHANGELOG heading on `chore/release-vX.Y.Z`, then push it + for a PR; +- main already says X.Y.Z everywhere: tag main `vX.Y.Z` and push the tag; release.yml publishes + the GitHub release from it. + +`--check [X.Y.Z]` only verifies every location agrees (release.yml runs it against the tag). +""" + +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +SEMVER = r"\d+\.\d+\.\d+" +VERSION_FILES = { + "plugins/marola-devkit/.claude-plugin/plugin.json": re.compile(rf'("version": ")({SEMVER})(")'), + "flake.nix": re.compile(rf'(version = ")({SEMVER})(";)'), +} +# A pin is a v-tag in one of these contexts; actions/checkout@v7 and history in CHANGELOG are not. +PIN = re.compile( + rf"(marola-devkit/v|marola-devkit/\.github/workflows/[\w.-]+@v|devkit-ref: v" + rf'|marola-devkit", "ref": "v|\*\*Status:\*\* this is v)({SEMVER})()' +) +PIN_GLOBS = ["README.md", "docs/**/*.md", ".github/workflows/*.yml"] + + +def locations(root): + for rel, rx in VERSION_FILES.items(): + yield root / rel, rx + for g in PIN_GLOBS: + for p in sorted(root.glob(g)): + yield p, PIN + + +def found(root): + """Every (file, version) the release touches.""" + return [ + (p.relative_to(root).as_posix(), m.group(2)) + for p, rx in locations(root) + for m in rx.finditer(p.read_text()) + ] + + +def mismatches(root, version): + hits = found(root) + missing = [f for f in VERSION_FILES if f not in {h[0] for h in hits}] + return [f"{f}: no version found" for f in missing] + [ + f"{f}: {v}" for f, v in hits if v != version + ] + + +def bump(root, version): + for p, rx in locations(root): + text = p.read_text() + new = rx.sub(lambda m: m.group(1) + version + m.group(3), text) + if new != text: + p.write_text(new) + + +def changelog(root, version, date, subjects): + p = root / "CHANGELOG.md" + text = p.read_text() + if f"## v{version} " in text: + return + entry = f"## v{version} — {date}\n\n" + "".join(f"- {s}\n" for s in subjects) + "\n" + i = text.find("\n## ") + p.write_text(text[: i + 1] + entry + text[i + 1 :] if i >= 0 else text + "\n" + entry) + + +def git(*args, cwd=None): + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def die(msg): + sys.exit(f"release: {msg}") + + +def release(version, dry_run): + root = Path(git("rev-parse", "--show-toplevel")) + tag = f"v{version}" + if git("branch", "--show-current", cwd=root) != "main": + die("check out main first") + if git("status", "--porcelain", "--untracked-files=no", cwd=root): + die("the working tree is dirty; commit or stash first") + git("fetch", "-q", "--tags", "origin", "main", cwd=root) + if git("rev-parse", "HEAD", cwd=root) != git("rev-parse", "origin/main", cwd=root): + die("main is not level with origin/main; pull or push first") + if git("tag", "-l", tag, cwd=root): + die(f"tag {tag} already exists") + prev = (git("tag", "-l", "v*", "--sort=-v:refname", cwd=root).splitlines() or [""])[0] + + if not mismatches(root, version): + if f"## {tag} " not in (root / "CHANGELOG.md").read_text(): + die(f"CHANGELOG.md has no '## {tag}' heading") + print( + f"release: {tag} at {git('log', '-1', '--format=%h %s', cwd=root)} (previous: {prev or 'none'})", + file=sys.stderr, + ) + cmds = [["tag", "-a", tag, "-m", f"marola-devkit {tag}"], ["push", "origin", tag]] + for c in cmds: + print("+ git " + " ".join(c), file=sys.stderr) + if not dry_run: + git(*c, cwd=root) + if not dry_run: + print( + f"release: pushed {tag}; release.yml publishes the GitHub release.", file=sys.stderr + ) + return + + branch = f"chore/release-{tag}" + log_range = f"{prev}..HEAD" if prev else "HEAD" + subjects = git("log", "--no-merges", "--format=%s", log_range, cwd=root).splitlines() + if dry_run: + files = sorted({f for f, _ in found(root)}) + print(f"release: would set {version} in {', '.join(files)} on {branch}", file=sys.stderr) + return + git("switch", "-c", branch, cwd=root) + bump(root, version) + changelog(root, version, git("log", "-1", "--format=%cs", cwd=root), subjects) + git( + "commit", + "-aqm", + f"release: {tag}\n\nTested: python3 scripts/release.py --check {version}\nCost: n/a (release script)", + cwd=root, + ) + git("push", "-u", "origin", branch, cwd=root) + print( + f"release: pushed {branch}. Edit the CHANGELOG entry into prose, open the PR (`just pr`), " + f"and after it merges run `just release {version}` again on main to tag it.", + file=sys.stderr, + ) + + +def self_test(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + files = { + "plugins/marola-devkit/.claude-plugin/plugin.json": '{\n "version": "0.5.1",\n}\n', + "flake.nix": 'version = "0.5.1";\n', + "README.md": ( + "**Status:** this is v0.5.1; each repo\n" + 'url = "github:marola-dev/marola-devkit/v0.5.1";\n' + '"source": { "source": "github", "repo": "marola-dev/marola-devkit", "ref": "v0.4.0" }\n' + "- uses: actions/checkout@v7.0.0\n" + ), + "docs/4-reference_workflows.md": ( + "uses: marola-dev/marola-devkit/.github/workflows/scala-ci.yml@v0.5.1\n devkit-ref: v0.5.1\n" + ), + ".github/workflows/notify-umbrella.yml": "# uses: marola-dev/marola-devkit/.github/workflows/n.yml@v0.5.1\n", + "CHANGELOG.md": "# Changelog\n\nIntro.\n\n## v0.5.1 — 2026-10-07\n\n- old\n", + } + for rel, text in files.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + assert len(found(root)) == 8, found(root) + assert any("0.4.0" in m for m in mismatches(root, "0.5.1")), "a stale pin went unnoticed" + bump(root, "0.6.0") + assert not mismatches(root, "0.6.0"), mismatches(root, "0.6.0") + assert "actions/checkout@v7.0.0" in (root / "README.md").read_text(), ( + "a third-party pin moved" + ) + changelog(root, "0.6.0", "2026-10-08", ["feat: a"]) + changelog(root, "0.6.0", "2026-10-08", ["feat: a"]) + cl = (root / "CHANGELOG.md").read_text() + assert cl.count("## v0.6.0") == 1 and cl.index("## v0.6.0") < cl.index("## v0.5.1"), cl + assert "Intro.\n\n## v0.6.0 — 2026-10-08\n\n- feat: a\n\n## v0.5.1" in cl, cl + (root / "flake.nix").write_text("nothing\n") + assert "flake.nix: no version found" in mismatches(root, "0.6.0") + print("release: self-test ok", file=sys.stderr) + + +def main(argv): + if argv[:1] == ["--self-test"]: + return self_test() + if argv[:1] == ["--check"]: + root = Path(git("rev-parse", "--show-toplevel")) + version = (argv[1] if len(argv) > 1 else "").removeprefix("v") + version = version or VERSION_FILES["flake.nix"].search( + (root / "flake.nix").read_text() + ).group(2) + bad = mismatches(root, version) + for b in bad: + print(f"release: expected {version}, {b}", file=sys.stderr) + sys.exit(1 if bad else 0) + args = [a for a in argv if a != "--dry-run"] + if len(args) != 1 or not re.fullmatch(rf"v?{SEMVER}", args[0]): + die("usage: just release [--dry-run]") + release(args[0].removeprefix("v"), "--dry-run" in argv) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/self-tests.sh b/tests/self-tests.sh index 968aa2a..f504f8c 100755 --- a/tests/self-tests.sh +++ b/tests/self-tests.sh @@ -14,7 +14,7 @@ sh_tests=( ) py_tests=( scripts/cost-split.py scripts/gemini_review.py scripts/pr_label_nlp.py scripts/workflow_runners.py scripts/docs_lint.py - scripts/skills_vendor.py + scripts/skills_vendor.py scripts/release.py scripts/lib/req_merge.py scripts/lib/uses_merge.py scripts/lib/mip_index_merge.py scripts/lib/tasks_issues.py plugins/marola-devkit/skills/voice-note-ingest/scripts/transcribe.py )