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
65 changes: 65 additions & 0 deletions .github/workflows/release-automation.yml
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict releases to main or push HEAD explicitly

This workflow can be manually dispatched from any branch, but the push step always runs git push origin main --follow-tags; when dispatched from a non-main ref, the release commit is created on the checked-out ref and then not pushed, so the version/changelog changes used to build the release are missing from main. This can produce a release/tag that does not correspond to the committed release metadata unless you either gate the job to refs/heads/main or push HEAD:main.

Useful? React with 👍 / 👎.

Comment on lines +57 to +58

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Push the new tag explicitly after creating it

The step creates a tag with git tag "v..." (lightweight tag) and then relies on git push ... --follow-tags; this does not reliably publish that newly created tag, so if the later action-gh-release step fails, the repository can end up with a release commit pushed but no remote vX.Y.Z tag. Push the tag directly (for example git push origin "v...") or create an annotated tag and push it.

Useful? React with 👍 / 👎.


- 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions docs/official-release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions docs/release-automation.md
Original file line number Diff line number Diff line change
@@ -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 `## <version>` section.
4. Generates `RELEASE_NOTES.md`.
5. Creates commit `chore: release <version>`.
6. Creates and pushes tag `v<version>`.
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`).
142 changes: 142 additions & 0 deletions scripts/release_automation.py
Original file line number Diff line number Diff line change
@@ -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()