diff --git a/.github/workflows/release-skills.yml b/.github/workflows/release-skills.yml index 14ade3b..071c54b 100644 --- a/.github/workflows/release-skills.yml +++ b/.github/workflows/release-skills.yml @@ -17,6 +17,10 @@ jobs: with: python-version: "3.12" - run: python -m pip install --upgrade pip pyyaml + - name: Stamp plugin version from release tag + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: python scripts/set_plugin_version.py --version "${GITHUB_REF_NAME#v}" - name: Validate plugin manifest shell: bash run: | @@ -36,7 +40,18 @@ jobs: with: python-version: "3.12" - run: python -m pip install --upgrade pip pyyaml - - run: python scripts/build_skill_archives.py --out dist + - name: Stamp plugin version from release tag + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: python scripts/set_plugin_version.py --version "${GITHUB_REF_NAME#v}" + - name: Build skill archives + shell: bash + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + python scripts/build_skill_archives.py --version "${GITHUB_REF_NAME#v}" --out dist + else + python scripts/build_skill_archives.py --out dist + fi - uses: actions/upload-artifact@v4 with: name: skill-archives diff --git a/README.md b/README.md index 3891bf7..1f93649 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,13 @@ python3 scripts/validate_plugin.py python3 scripts/validate_skills.py ``` -If you want Codex to load this repo as a local plugin, the simplest path is to expose this repo at `~/plugins/kedify-mcp` and use a local marketplace entry that points to `./plugins/kedify-mcp`. +If you want Codex to load this repo as a local plugin, the simplest path is to expose this repo under your local `plugins/` directory and use a local marketplace entry that points to `./plugins/kedify-mcp`. Example: ```bash -mkdir -p ~/plugins -ln -sfn /home/jkarasek/go/src/github.com/kedify/kedify-mcp ~/plugins/kedify-mcp +mkdir -p "$HOME/plugins" +ln -sfn "$PWD" "$HOME/plugins/kedify-mcp" ``` After the marketplace entry exists, use the normal local update flow: @@ -71,8 +71,7 @@ After the marketplace entry exists, use the normal local update flow: 1. Refresh the plugin cachebuster: ```bash -python3 /home/jkarasek/.codex/skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py \ - /home/jkarasek/go/src/github.com/kedify/kedify-mcp +python3 scripts/set_plugin_version.py --version 0.1.0 ``` 2. Reinstall the plugin from the configured local marketplace: @@ -95,4 +94,10 @@ Release artifacts are optimized for ChatGPT skill upload. - one zip artifact per skill - no separate whole-plugin zip release artifact -See [RELEASING.md](/home/jkarasek/go/src/github.com/kedify/kedify-mcp/RELEASING.md:1) for the exact artifact layout, versioning model, and GitHub Actions release flow. +See [`RELEASING.md`](RELEASING.md) for the exact artifact layout, versioning model, and GitHub Actions release flow. + +The preferred local release command is: + +```bash +python scripts/cut_release.py --version 0.1.0 --push +``` diff --git a/RELEASING.md b/RELEASING.md index 07f79e0..332c3d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -73,23 +73,47 @@ Reasons: Versioning convention: - Git tag: `v0.1.0` -- `.codex-plugin/plugin.json` version: `0.1.0` +- release-stamped `.codex-plugin/plugin.json` version: `0.1.0` - release asset: `kedify-mcp-autoscaling-debug-v0.1.0.zip` ## Release steps +Preferred local release command: + +```bash +python scripts/cut_release.py --version 0.1.0 --push +``` + +What it does: + +- requires a clean git worktree +- stamps `.codex-plugin/plugin.json` to `0.1.0` +- validates plugin and skills +- builds and verifies the skill archives locally +- commits the version bump with `release: v0.1.0` +- creates annotated tag `v0.1.0` +- pushes the current branch and tag when `--push` is passed + +Manual step-by-step equivalent: + 1. Update skill or plugin-packaging source files in this repo. 2. If needed, deploy compatible MCP backend changes from `dashboard-api-service` first. -3. Bump `.codex-plugin/plugin.json` `version`. -4. Merge to `main`. -5. Tag the release: +3. Merge to `main`. +4. Run: ```bash -git tag v0.1.0 +python scripts/cut_release.py --version 0.1.0 +``` + +5. If you did not use `--push`, push the branch and tag: + +```bash +git push origin HEAD git push origin v0.1.0 ``` 6. GitHub Actions will: + - stamp `.codex-plugin/plugin.json` to the tag version inside the CI workspace - validate plugin manifests - validate each skill - build one zip per skill into `dist/` @@ -102,12 +126,19 @@ Run the same checks locally before tagging: ```bash python -m pip install --upgrade pip pyyaml -python scripts/validate_plugin.py +python scripts/set_plugin_version.py --version 0.1.0 +python scripts/validate_plugin.py --expected-version 0.1.0 python scripts/validate_skills.py -python scripts/build_skill_archives.py --out dist +python scripts/build_skill_archives.py --version 0.1.0 --out dist python scripts/verify_archives.py dist ``` +You can also preview the release cutter without changing git state: + +```bash +python scripts/cut_release.py --version 0.1.0 --dry-run +``` + ## ChatGPT upload For manual ChatGPT skill upload, use the per-skill zip from the GitHub Release. diff --git a/scripts/cut_release.py b/scripts/cut_release.py new file mode 100644 index 0000000..86bb88c --- /dev/null +++ b/scripts/cut_release.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import sys + +from common import SEMVER_RE + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Cut a kedify-mcp release by stamping plugin.json, validating, " + "committing the version bump, creating a tag, and optionally pushing." + ) + ) + parser.add_argument("--version", required=True, help="Release version without the leading v") + parser.add_argument( + "--push", + action="store_true", + help="Push the current branch and release tag after creating them locally", + ) + parser.add_argument( + "--remote", + default="origin", + help="Git remote to push to when --push is used (default: origin)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the actions that would run without changing git state", + ) + args = parser.parse_args() + + version = args.version.strip() + if SEMVER_RE.fullmatch(version) is None: + raise SystemExit(f"Version `{version}` is not valid semver") + tag_name = f"v{version}" + + ensure_clean_worktree() + ensure_tag_missing(tag_name) + + if args.dry_run: + print(f"Would set plugin.json version to {version}") + print(f"Would validate plugin and skills for {tag_name}") + print(f"Would commit .codex-plugin/plugin.json with message: release: {tag_name}") + print(f"Would create annotated tag: {tag_name}") + if args.push: + branch = git_output("rev-parse", "--abbrev-ref", "HEAD") + print(f"Would push branch `{branch}` and tag `{tag_name}` to `{args.remote}`") + return + + run_python_script("scripts/set_plugin_version.py", "--version", version) + run_python_script("scripts/validate_plugin.py", "--expected-version", version) + run_python_script("scripts/validate_skills.py") + run_python_script("scripts/build_skill_archives.py", "--version", version, "--out", "dist") + run_python_script("scripts/verify_archives.py", "dist") + + git("add", ".codex-plugin/plugin.json") + diff = subprocess.run(["git", "diff", "--cached", "--quiet"], check=False) + if diff.returncode == 0: + print("No plugin.json changes to commit; tagging current HEAD") + else: + git("commit", "-m", f"release: {tag_name}") + git("tag", "-a", tag_name, "-m", tag_name) + + print(f"Created release commit and tag {tag_name}") + + if args.push: + branch = git_output("rev-parse", "--abbrev-ref", "HEAD") + git("push", args.remote, branch) + git("push", args.remote, tag_name) + print(f"Pushed branch `{branch}` and tag `{tag_name}` to `{args.remote}`") + else: + print(f"Next step: git push origin HEAD && git push origin {tag_name}") + + +def ensure_clean_worktree() -> None: + status = git_output("status", "--short") + if status: + raise SystemExit( + "Refusing to cut a release from a dirty worktree.\n" + "Commit or stash your changes first, then rerun the release command." + ) + + +def ensure_tag_missing(tag_name: str) -> None: + result = subprocess.run( + ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag_name}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + raise SystemExit(f"Git tag `{tag_name}` already exists") + + +def run_python_script(script: str, *script_args: str) -> None: + cmd = [sys.executable, script, *script_args] + print("+", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +def git(*git_args: str) -> None: + cmd = ["git", *git_args] + print("+", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +def git_output(*git_args: str) -> str: + result = subprocess.run( + ["git", *git_args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +if __name__ == "__main__": + main() diff --git a/scripts/set_plugin_version.py b/scripts/set_plugin_version.py new file mode 100644 index 0000000..0a986e3 --- /dev/null +++ b/scripts/set_plugin_version.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json + +from common import PLUGIN_MANIFEST_PATH, SEMVER_RE, load_json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Set .codex-plugin/plugin.json version to a specific release value." + ) + parser.add_argument("--version", required=True, help="Semver version to write") + args = parser.parse_args() + + version = args.version.strip() + if SEMVER_RE.fullmatch(version) is None: + raise SystemExit(f"Version `{version}` is not valid semver") + + payload = load_json(PLUGIN_MANIFEST_PATH) + if not isinstance(payload, dict): + raise SystemExit(f"{PLUGIN_MANIFEST_PATH} must contain a JSON object") + + old_version = payload.get("version") + payload["version"] = version + with PLUGIN_MANIFEST_PATH.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + handle.write("\n") + + if old_version == version: + print(f"Plugin version already set to {version}") + else: + print(f"Updated plugin version from {old_version!r} to {version!r}") + + +if __name__ == "__main__": + main()