From a96771a8c9bd3a21cda86dc6ed7bdc7454c3081b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E7=99=BD?= <31078449+Nowhitestar@users.noreply.github.com> Date: Fri, 29 May 2026 14:49:35 +0800 Subject: [PATCH] feat(ci): automate release publishing --- .github/workflows/ci.yml | 97 +++++++++++++ CONTRIBUTING.md | 22 ++- docs/distribution.md | 42 +++++- scripts/plan_release.py | 285 +++++++++++++++++++++++++++++++++++++ tests/test_plan_release.py | 122 ++++++++++++++++ 5 files changed, 560 insertions(+), 8 deletions(-) create mode 100644 scripts/plan_release.py create mode 100644 tests/test_plan_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b13ffbe..f05327a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,24 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + inputs: + release: + description: Force a release bump + required: false + default: auto + type: choice + options: + - auto + - patch + - minor + - major + version: + description: Exact release version, e.g. 0.6.0 + required: false + +permissions: + contents: read jobs: test: @@ -48,3 +66,82 @@ jobs: # job-local dir (the smoke script also overrides XDG_CONFIG_HOME). HOME: ${{ runner.temp }} run: python scripts/test_local.py + + release: + name: Release + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + permissions: + contents: write + concurrency: + group: meti-release-main + cancel-in-progress: false + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Plan release + id: plan + env: + RELEASE_BUMP: ${{ github.event_name == 'workflow_dispatch' && inputs.release || 'auto' }} + RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + run: | + python scripts/plan_release.py \ + --force-bump "$RELEASE_BUMP" \ + --version "$RELEASE_VERSION" \ + --github-output + + - name: Show release decision + run: | + echo "should_release=${{ steps.plan.outputs.should_release }}" + echo "version=${{ steps.plan.outputs.version }}" + echo "reason=${{ steps.plan.outputs.reason }}" + + - name: Prepare release version + if: steps.plan.outputs.should_release == 'true' + run: python scripts/release.py prepare --version "${{ steps.plan.outputs.version }}" + + - name: Commit release version + if: steps.plan.outputs.should_release == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add release.json pyproject.toml SKILL.md .claude-plugin/plugin.json .claude-plugin/marketplace.json CHANGELOG.md + git commit -m "chore(release): prepare ${{ steps.plan.outputs.version }}" + + - name: Verify and build release + if: steps.plan.outputs.should_release == 'true' + run: | + python scripts/check_release.py + python scripts/release.py build + + - name: Push release commit and tag + if: steps.plan.outputs.should_release == 'true' + run: | + git tag "${{ steps.plan.outputs.tag }}" + git push --atomic origin HEAD:main "${{ steps.plan.outputs.tag }}" + + - name: Publish GitHub Release + if: steps.plan.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ steps.plan.outputs.tag }}" \ + dist/releases/${{ steps.plan.outputs.tag }}/* \ + --title "${{ steps.plan.outputs.tag }}" \ + --notes-file CHANGELOG.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b54aa17..83c016c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,11 +78,23 @@ create live platform drafts, open a browser, read real credentials, or submit marketplace data. `release.json` is the canonical publication source for versioned releases. -Use the semi-automatic release workflow so Python package metadata, SKILL -frontmatter, plugin metadata, marketplace metadata, artifacts, changelog, tag, -and GitHub Release stay in sync: +Normal releases are created by CI from `main` after the full test matrix passes. +CI updates Python package metadata, SKILL frontmatter, plugin metadata, +marketplace metadata, artifacts, changelog, tag, and GitHub Release together. + +Release selection is intentionally conservative: + +- `feat(scope): ...` or `perf(scope): ...` creates a minor release. +- `feat(scope)!: ...` or a `BREAKING CHANGE:` footer creates a major release. +- `release: patch`, `release: minor`, or `release: major` in the commit body + can force a release when the Conventional Commit type is not enough. +- Routine `fix:`, `docs:`, `test:`, `chore:`, `ci:`, and `refactor:` commits do + not publish by default. + +Use the local dry-run commands only when reviewing or recovering a release: ```bash +python scripts/plan_release.py python scripts/release.py prepare --version X.Y.Z --dry-run python scripts/release.py build --dry-run python scripts/release.py publish --version X.Y.Z --dry-run @@ -134,10 +146,10 @@ platform artifacts in tracked files. ## Commits, PRs, releases -- **Commit messages**: imperative present tense, scope-prefixed when it helps (`feat(substack):`, `fix(wechat-image):`, `docs:`, `refactor(browser):`). The recent git log is a good style reference. +- **Commit messages**: imperative present tense, scope-prefixed when it helps (`feat(substack):`, `fix(wechat-image):`, `docs:`, `refactor(browser):`). CI uses `feat`, `perf`, `!`, `BREAKING CHANGE:`, and `release:*` markers to decide whether to publish a release, so reserve those signals for important user-visible changes. - **PRs** target `main`. Squash-merge by default. PR description should answer: what, why, how-verified. - **Tests required** for any code change. New providers ship with both unit tests and a real-account verification note. -- **Releases**: maintainers publish `vMAJOR.MINOR.PATCH` from `main` after CI green and `python scripts/check_release.py` passes; the `release.json` bump lands in the same PR as the user-visible feature. +- **Releases**: CI publishes `vMAJOR.MINOR.PATCH` from `main` after CI green and `python scripts/check_release.py` passes. The `release.json` bump lands in the CI-created release commit. ### Prefer reinstall diff --git a/docs/distribution.md b/docs/distribution.md index 6ad08ae..b2fd1ff 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -132,17 +132,53 @@ private paths, generated wheel/release bundle contents, install docs, and the no-network smoke flow. It does not create live platform drafts, open a browser, read real credentials, submit marketplace data, or publish anything publicly. -Release maintainers build and publish with dry-run-first commands: +## Automated release policy + +CI owns normal releases from `main`. After the full macOS + Ubuntu test matrix +passes, the release job inspects commits since the latest `vX.Y.Z` tag and +publishes only when there is a release-worthy signal. + +Automatic release signals: + +- `feat(scope): ...` or `perf(scope): ...` creates a minor release. +- `feat(scope)!: ...` or a `BREAKING CHANGE:` footer creates a major release. +- `release: patch`, `release: minor`, or `release: major` in the commit body + overrides the automatic choice. + +Routine `fix:`, `docs:`, `test:`, `chore:`, `ci:`, and `refactor:` commits do +not publish a release by default. This keeps the release stream focused on +important user-visible changes instead of every small maintenance update. + +When a release is selected, CI runs: + +```bash +python scripts/plan_release.py --github-output +python scripts/release.py prepare --version X.Y.Z +python scripts/check_release.py +python scripts/release.py build +``` + +Then CI commits the synchronized version files, atomically pushes `main` and +`vX.Y.Z`, and creates the GitHub Release with the generated artifacts from +`dist/releases/vX.Y.Z/`. + +Manual override is available from the CI workflow dispatch UI for exceptional +cases: choose `patch`, `minor`, or `major`, or provide an exact `X.Y.Z` version. +Use this sparingly; the default path should be normal commits to `main`. + +Release maintainers can still inspect the same flow locally with dry-run-first +commands: ```bash +python scripts/plan_release.py python scripts/release.py prepare --version X.Y.Z --dry-run python scripts/release.py build --dry-run python scripts/release.py publish --version X.Y.Z --dry-run ``` -Confirmed publish creates the `vX.Y.Z` tag and published GitHub Release only +Manual local publish creates the `vX.Y.Z` tag and published GitHub Release only after the maintainer approves the printed target version, artifact list, -checksums, and `gh release create` command. +checksums, and `gh release create` command. Prefer CI for routine releases. ## Private artifacts diff --git a/scripts/plan_release.py b/scripts/plan_release.py new file mode 100644 index 0000000..13ca2c9 --- /dev/null +++ b/scripts/plan_release.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Decide whether CI should publish a Meti release.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from core.release import load_release_manifest # noqa: E402 + +BUMP_ORDER = {"none": 0, "patch": 1, "minor": 2, "major": 3} +RELEASE_BUMPS = ("auto", "patch", "minor", "major") +SEMVER_RE = re.compile(r"^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +CONVENTIONAL_RE = re.compile(r"^(?P[a-z]+)(?:\([^)]+\))?(?P!)?:") +BREAKING_RE = re.compile(r"(?im)^BREAKING[ -]CHANGE:") +RELEASE_OVERRIDE_RE = re.compile( + r"(?im)(?:^\s*release:\s*(major|minor|patch)\b|\[release:\s*(major|minor|patch)\])" +) + +Version = tuple[int, int, int] + + +@dataclass(frozen=True) +class ReleasePlan: + """Serializable release decision consumed by GitHub Actions.""" + + should_release: bool + version: str + tag: str + bump: str + latest_tag: str + reason: str + + +def parse_version(version: str) -> Version: + """Parse strict SemVer, allowing an optional leading v.""" + + match = SEMVER_RE.match(version.strip()) + if not match: + raise ValueError(f"{version} is not strict SemVer X.Y.Z") + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def format_version(version: Version) -> str: + """Format a SemVer tuple.""" + + return ".".join(str(part) for part in version) + + +def bump_version(version: str, bump: str) -> str: + """Return the next SemVer for a patch, minor, or major bump.""" + + major, minor, patch = parse_version(version) + if bump == "patch": + return format_version((major, minor, patch + 1)) + if bump == "minor": + return format_version((major, minor + 1, 0)) + if bump == "major": + return format_version((major + 1, 0, 0)) + raise ValueError(f"unsupported release bump: {bump}") + + +def release_signal_from_message(message: str) -> str: + """Return the release bump implied by one commit message.""" + + override = RELEASE_OVERRIDE_RE.search(message) + if override: + return next(group for group in override.groups() if group) + + if BREAKING_RE.search(message): + return "major" + + subject = message.strip().splitlines()[0] if message.strip() else "" + conventional = CONVENTIONAL_RE.match(subject) + if not conventional: + return "none" + if conventional.group("breaking"): + return "major" + if conventional.group("type") in {"feat", "perf"}: + return "minor" + return "none" + + +def highest_release_signal(messages: list[str]) -> tuple[str, list[str]]: + """Return the strongest automatic release signal and matching subjects.""" + + bump = "none" + matches: list[str] = [] + for message in messages: + signal = release_signal_from_message(message) + if signal == "none": + continue + subject = message.strip().splitlines()[0] + matches.append(f"{signal}: {subject}") + if BUMP_ORDER[signal] > BUMP_ORDER[bump]: + bump = signal + return bump, matches + + +def select_release_plan( + current_version: str, + messages: list[str], + *, + force_bump: str = "auto", + explicit_version: str = "", + latest_tag: str = "", +) -> ReleasePlan: + """Select the target release version from commit messages and overrides.""" + + if force_bump not in RELEASE_BUMPS: + raise ValueError(f"unsupported release bump: {force_bump}") + + current = parse_version(current_version) + if explicit_version: + target = parse_version(explicit_version) + if target <= current: + raise ValueError( + f"explicit release version {explicit_version} must be greater than {current_version}" + ) + version = format_version(target) + return ReleasePlan( + should_release=True, + version=version, + tag=f"v{version}", + bump="exact", + latest_tag=latest_tag, + reason=f"Manual exact release version requested: {version}.", + ) + + if force_bump != "auto": + version = bump_version(current_version, force_bump) + return ReleasePlan( + should_release=True, + version=version, + tag=f"v{version}", + bump=force_bump, + latest_tag=latest_tag, + reason=f"Manual {force_bump} release requested.", + ) + + bump, matches = highest_release_signal(messages) + if bump == "none": + boundary = latest_tag or "the previous release" + return ReleasePlan( + should_release=False, + version=current_version, + tag=f"v{current_version}", + bump="none", + latest_tag=latest_tag, + reason=f"No release-worthy commits since {boundary}.", + ) + + version = bump_version(current_version, bump) + examples = "; ".join(matches[:3]) + suffix = "..." if len(matches) > 3 else "" + return ReleasePlan( + should_release=True, + version=version, + tag=f"v{version}", + bump=bump, + latest_tag=latest_tag, + reason=f"Automatic {bump} release from {examples}{suffix}.", + ) + + +def _run_git(project_root: Path, args: list[str]) -> str: + result = subprocess.run( + ["git", *args], + cwd=project_root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def latest_semver_tag(project_root: Path) -> str: + """Return the newest vX.Y.Z tag, or an empty string if none exists.""" + + output = _run_git( + project_root, + ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"], + ) + for tag in output.splitlines(): + try: + parse_version(tag) + except ValueError: + continue + return tag + return "" + + +def commit_messages_since(project_root: Path, tag: str) -> list[str]: + """Return commit messages after the release tag.""" + + if not tag: + return [] + output = _run_git(project_root, ["log", "--format=%B%x1e", f"{tag}..HEAD"]) + return [message.strip() for message in output.split("\x1e") if message.strip()] + + +def _write_github_output(plan: ReleasePlan, output_path: str) -> None: + def safe(value: object) -> str: + return str(value).replace("\n", " ").replace("\r", " ") + + lines = [ + f"should_release={str(plan.should_release).lower()}", + f"version={safe(plan.version)}", + f"tag={safe(plan.tag)}", + f"bump={safe(plan.bump)}", + f"latest_tag={safe(plan.latest_tag)}", + f"reason={safe(plan.reason)}", + ] + with Path(output_path).open("a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--project-root", + type=Path, + default=ROOT, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--force-bump", + choices=RELEASE_BUMPS, + default="auto", + help="Force a patch/minor/major release instead of commit-message detection.", + ) + parser.add_argument( + "--version", + default="", + help="Publish an exact X.Y.Z version. Overrides --force-bump.", + ) + parser.add_argument( + "--github-output", + action="store_true", + help="Append release decision keys to GITHUB_OUTPUT.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + project_root = args.project_root.resolve() + try: + current_version = str(load_release_manifest(project_root)["version"]) + latest_tag = latest_semver_tag(project_root) + messages = commit_messages_since(project_root, latest_tag) + plan = select_release_plan( + current_version, + messages, + force_bump=args.force_bump, + explicit_version=args.version.strip(), + latest_tag=latest_tag, + ) + except (subprocess.CalledProcessError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(asdict(plan), indent=2)) + if args.github_output: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + print("ERROR: GITHUB_OUTPUT is not set", file=sys.stderr) + return 1 + _write_github_output(plan, output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_plan_release.py b/tests/test_plan_release.py new file mode 100644 index 0000000..cd17fab --- /dev/null +++ b/tests/test_plan_release.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "plan_release.py" + + +def load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("plan_release", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["plan_release"] = module + spec.loader.exec_module(module) + return module + + +def test_routine_changes_do_not_trigger_auto_release() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + [ + "fix(cli): handle empty drafts", + "docs: clarify install path", + "chore: update dev tooling", + ], + latest_tag="v0.5.1", + ) + + assert plan.should_release is False + assert plan.version == "0.5.1" + assert plan.bump == "none" + + +def test_feature_commit_triggers_minor_release() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + ["feat(release): install by version"], + latest_tag="v0.5.1", + ) + + assert plan.should_release is True + assert plan.version == "0.6.0" + assert plan.tag == "v0.6.0" + assert plan.bump == "minor" + + +def test_breaking_commit_triggers_major_release() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + ["feat(cli)!: change manifest contract"], + latest_tag="v0.5.1", + ) + + assert plan.should_release is True + assert plan.version == "1.0.0" + assert plan.bump == "major" + + +def test_release_marker_can_force_patch_release() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + [ + "fix(installer): make checksum errors clearer\n\n release: patch", + "docs: refresh examples", + ], + latest_tag="v0.5.1", + ) + + assert plan.should_release is True + assert plan.version == "0.5.2" + assert plan.bump == "patch" + + +def test_manual_force_bump_overrides_commit_messages() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + ["docs: refresh examples"], + force_bump="minor", + latest_tag="v0.5.1", + ) + + assert plan.should_release is True + assert plan.version == "0.6.0" + assert plan.reason == "Manual minor release requested." + + +def test_manual_exact_version_must_move_forward() -> None: + plan_release = load_module() + + plan = plan_release.select_release_plan( + "0.5.1", + [], + explicit_version="0.7.0", + latest_tag="v0.5.1", + ) + + assert plan.should_release is True + assert plan.version == "0.7.0" + assert plan.bump == "exact" + + with pytest.raises(ValueError): + plan_release.select_release_plan( + "0.5.1", + [], + explicit_version="0.5.1", + latest_tag="v0.5.1", + )