From a8a2ab3cacf0084ede35fb6d90986365b2176c50 Mon Sep 17 00:00:00 2001 From: Aleksandr Lozhkovoi Date: Sat, 21 Feb 2026 21:24:34 +0100 Subject: [PATCH] ci: add automated release workflow --- .github/workflows/release-automation.yml | 65 +++++++++++ CHANGELOG.md | 5 + README.md | 4 + docs/official-release-checklist.md | 1 + docs/release-automation.md | 30 +++++ scripts/release_automation.py | 142 +++++++++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 .github/workflows/release-automation.yml create mode 100644 docs/release-automation.md create mode 100755 scripts/release_automation.py diff --git a/.github/workflows/release-automation.yml b/.github/workflows/release-automation.yml new file mode 100644 index 0000000..d21993b --- /dev/null +++ b/.github/workflows/release-automation.yml @@ -0,0 +1,65 @@ +name: Release Automation + +on: + workflow_dispatch: + inputs: + version: + description: "Explicit release version (e.g. 1.10.0). Leave empty to use bump." + required: false + type: string + bump: + description: "Semver bump when version is empty." + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Prepare release metadata + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + python3 scripts/release_automation.py --version "${{ github.event.inputs.version }}" + else + python3 scripts/release_automation.py --bump "${{ github.event.inputs.bump }}" + fi + + - name: Read release version + id: release_version + run: echo "value=$(cat .release-version)" >> "$GITHUB_OUTPUT" + + - name: Commit release files + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add plugin.json .cursor-plugin/plugin.json CHANGELOG.md RELEASE_NOTES.md .release-version + git commit -m "chore: release ${{ steps.release_version.outputs.value }}" + + - name: Create and push tag + run: | + git tag "v${{ steps.release_version.outputs.value }}" + git push origin main --follow-tags + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.release_version.outputs.value }} + name: v${{ steps.release_version.outputs.value }} + body_path: RELEASE_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 21874d0..043e759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Added release automation workflow: + - `.github/workflows/release-automation.yml` + - `scripts/release_automation.py` + - `docs/release-automation.md` +- Release automation now performs semver bump, changelog section cut, tag creation, and GitHub Release publishing from `RELEASE_NOTES.md`. - Fixed Flutter AI rules profile sync metadata so only `rules/flutter-official-ai-rules.mdc` contains Cursor `globs` auto-attach front matter; synced `rules/official/*.mdc` remain non-attaching source profiles. - Added official Flutter AI rules sync workflow: - command: `commands/sync-official-flutter-ai-rules.md` diff --git a/README.md b/README.md index bb6ddbf..c82899b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Three ready-to-run scenarios: ## Included assets - **Release checklist**: `docs/official-release-checklist.md` for official/public release prep. +- **Release automation**: `docs/release-automation.md` for semver bump, changelog cut, tag, and GitHub Release publishing. - **Security posture**: `docs/security-posture.md` for `/security-review` scope, false-positive handling, and CI integration. - **Agents** - `flutter-app-builder` (general Flutter implementation) @@ -168,6 +169,9 @@ Three ready-to-run scenarios: 12. To sync official Flutter AI rules profiles (`10k`/`4k`/`1k`), use: - `sync-official-flutter-ai-rules` - `docs/flutter-ai-rules-sync.md` +13. For automated release cut (version/changelog/tag/release), use: + - `.github/workflows/release-automation.yml` + - `docs/release-automation.md` Note: every code review flow includes mandatory security checks (OWASP MASVS-oriented). diff --git a/docs/official-release-checklist.md b/docs/official-release-checklist.md index ce3124d..617c3a2 100644 --- a/docs/official-release-checklist.md +++ b/docs/official-release-checklist.md @@ -26,6 +26,7 @@ Use this checklist before tagging a public release. - `LICENSE`, `CONTRIBUTING.md`, and `CODE_OF_CONDUCT.md` are present. - Repository description, topics, and social preview are configured. - First release tag and GitHub Release notes are prepared. +- Release automation workflow exists and is documented (`.github/workflows/release-automation.yml`, `docs/release-automation.md`). ## Mobile publication readiness diff --git a/docs/release-automation.md b/docs/release-automation.md new file mode 100644 index 0000000..c8f9ec9 --- /dev/null +++ b/docs/release-automation.md @@ -0,0 +1,30 @@ +# Release Automation + +Use the `Release Automation` GitHub workflow to cut a release from `CHANGELOG.md`, tag it, and publish GitHub Release notes. + +## What it does + +1. Resolves target version: + - explicit `version` input, or + - semver `bump` input (`patch`, `minor`, `major`). +2. Syncs version in: + - `plugin.json` + - `.cursor-plugin/plugin.json` +3. Moves `## Unreleased` notes into a new `## ` section. +4. Generates `RELEASE_NOTES.md`. +5. Creates commit `chore: release `. +6. Creates and pushes tag `v`. +7. Publishes GitHub Release from `RELEASE_NOTES.md`. + +## Required preconditions + +- `CHANGELOG.md` has non-empty `## Unreleased` notes. +- Manifest versions are aligned before running. +- Branch protection allows GitHub Actions bot to push release commit/tag. + +## Running it + +In GitHub: `Actions` -> `Release Automation` -> `Run workflow`. + +- Option A: set explicit `version` (for example, `1.10.0`). +- Option B: leave `version` empty and choose `bump` (`patch`/`minor`/`major`). diff --git a/scripts/release_automation.py b/scripts/release_automation.py new file mode 100755 index 0000000..60def25 --- /dev/null +++ b/scripts/release_automation.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +from pathlib import Path + + +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_RE.match(value) + if not match: + raise ValueError(f"Invalid semver: {value}") + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def bump_version(current: str, bump: str) -> str: + major, minor, patch = parse_semver(current) + if bump == "patch": + patch += 1 + elif bump == "minor": + minor += 1 + patch = 0 + elif bump == "major": + major += 1 + minor = 0 + patch = 0 + else: + raise ValueError(f"Unsupported bump type: {bump}") + return f"{major}.{minor}.{patch}" + + +def load_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def save_json(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def update_manifest_versions(root_manifest: Path, canonical_manifest: Path, target_version: str) -> None: + root_data = load_json(root_manifest) + canonical_data = load_json(canonical_manifest) + + root_version = root_data.get("version") + canonical_version = canonical_data.get("version") + if root_version != canonical_version: + raise ValueError( + f"Manifest version mismatch: plugin.json={root_version}, .cursor-plugin/plugin.json={canonical_version}" + ) + + root_data["version"] = target_version + canonical_data["version"] = target_version + save_json(root_manifest, root_data) + save_json(canonical_manifest, canonical_data) + + +def normalize_unreleased_block(content: str) -> tuple[str, str]: + marker = "## Unreleased\n" + start = content.find(marker) + if start == -1: + raise ValueError("CHANGELOG.md must contain '## Unreleased' section.") + + after = content[start + len(marker) :] + next_header = after.find("\n## ") + if next_header == -1: + unreleased_body = after + rest = "" + else: + unreleased_body = after[:next_header] + rest = after[next_header + 1 :] + + body_stripped = unreleased_body.strip("\n") + if not body_stripped.strip(): + raise ValueError("Unreleased section is empty. Add release notes before publishing.") + + return body_stripped + "\n", rest + + +def update_changelog(changelog_path: Path, version: str) -> str: + content = changelog_path.read_text(encoding="utf-8") + unreleased_body, rest = normalize_unreleased_block(content) + + if f"\n## {version}\n" in content: + raise ValueError(f"CHANGELOG already contains section for {version}") + + rebuilt = ( + "# Changelog\n\n" + "## Unreleased\n\n" + f"## {version}\n\n" + f"{unreleased_body}\n" + f"{rest}" + ) + changelog_path.write_text(rebuilt, encoding="utf-8") + return unreleased_body + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare release files for flutter-cursor-plugin.") + parser.add_argument("--version", help="Target release version (e.g., 1.10.0).") + parser.add_argument( + "--bump", + choices=["patch", "minor", "major"], + help="Optional semver bump from current version when --version is not set.", + ) + parser.add_argument("--repo-root", default=".", help="Repository root path.") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + root_manifest = repo_root / "plugin.json" + canonical_manifest = repo_root / ".cursor-plugin" / "plugin.json" + changelog = repo_root / "CHANGELOG.md" + notes_file = repo_root / "RELEASE_NOTES.md" + version_file = repo_root / ".release-version" + + root_data = load_json(root_manifest) + canonical_data = load_json(canonical_manifest) + current_version = root_data.get("version") + if current_version != canonical_data.get("version"): + raise ValueError("Manifest versions must match before release automation.") + parse_semver(current_version) + + if args.version: + target_version = args.version + parse_semver(target_version) + elif args.bump: + target_version = bump_version(current_version, args.bump) + else: + target_version = current_version + + update_manifest_versions(root_manifest, canonical_manifest, target_version) + release_notes = update_changelog(changelog, target_version) + + notes_file.write_text(release_notes, encoding="utf-8") + version_file.write_text(target_version + "\n", encoding="utf-8") + print(f"Prepared release {target_version}") + + +if __name__ == "__main__": + main()