diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8af8c1c..ea8de65 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,3 +30,25 @@ jobs: run: python -m unittest discover -s tests -v - name: Check whitespace run: git diff --check + + release: + needs: validate + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + concurrency: + group: plugin-release + cancel-in-progress: false + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: Publish version tag and GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: python scripts/release.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f41ac33..dd9d4bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ Claude validator의 성공은 Skill 행동이나 OAuth 성공을 증명하지 1. portable manifest의 version을 갱신하고 `scripts/sync_manifests.py`를 실행합니다. 2. 정적 검증·회귀 검사·변경된 workflow의 동작 평가를 통과시킵니다. -3. main 변경의 GitHub Actions 성공을 확인합니다. +3. main 변경의 **Validate plugin** Actions에서 검증과 `release` job의 성공, `v` 태그 및 GitHub Release를 확인합니다. 같은 버전은 재발행하지 않으며, 실패 복구는 해당 workflow를 `main`에서 수동 실행합니다. 태그를 강제로 이동하지 않습니다. 4. 두 앱·두 CLI에서 repo 동기화/업데이트 후 설치·새 세션 Skill 발견·OAuth와 연결 source·직접 App/SuperApp 업무 상태를 각각 확인합니다. 수동 MCP가 공존할 때 같은 이름 도구의 성공만으로 Plugin 전용 연결이라 주장하지 않습니다. 5. 검증한 client 버전, repo commit, MCP 환경, 완료 범위와 제한을 `docs/compatibility.md`에 기록합니다. diff --git a/README.md b/README.md index b9f5419..7688ddb 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ python3 -m unittest discover -s tests -v Metadata·version은 `plugins/ennoia/plugin.json`, MCP 설정은 `plugins/ennoia/mcp.json`에서 수정한 뒤 `python3 scripts/sync_manifests.py`로 호환 파일을 생성합니다. 이 명령은 공식 asset 원본에서 다크 모드 로고와 Skill별 아이콘 복사본도 생성합니다. Skill은 `plugins/ennoia/skills` 원본에서 수정합니다. 동일한 버전을 덮어쓰지 말고 릴리스 시 version을 올려 host cache를 갱신합니다. +`main`에 반영되면 **Validate plugin** workflow의 검증 성공 후 manifest version으로 `v` 태그와 GitHub Release를 자동 생성합니다. 현재 `1.1.0`이면 `v1.1.0`을 발행하며, 릴리스 노트는 [GitHub Release API](https://docs.github.com/en/rest/releases/releases#create-a-release)가 병합 PR 기준으로 생성합니다. `1.2.0-rc.1` 같은 사전 버전은 prerelease로 표시합니다. 별도 PAT 없이 발행 job의 `GITHUB_TOKEN`에만 `contents: write` 권한을 부여합니다. + +이미 발행된 버전은 태그와 Release를 변경하지 않습니다. 태그만 생성되고 Release 생성이 실패했다면 Actions → **Validate plugin** → **Run workflow**에서 `main`을 선택해 같은 태그의 Release를 복구할 수 있습니다. 기존 태그의 version·main 이력이 맞지 않거나 기존 draft Release가 있으면 중단합니다. PR·다른 branch·tag push에서는 발행하지 않으며, 자동화 도입 전 태그를 일괄 발행하지 않습니다. 앱 설치는 계속 Git repo Marketplace를 사용합니다. + 아이콘·로고 출처, 브랜드 색상과 host별 표시 범위는 [브랜드 자산](docs/branding.md)을 참고합니다. [기여·검증 기준](CONTRIBUTING.md)과 [동작 평가](evals/README.md)를 따라 검증합니다. CI는 credential 없이 패키지 구조와 회귀 테스트를 실행합니다. Native client 검증, OAuth, 실제 MCP 업무 결과와 latency 개선은 별도로 확인해야 합니다. diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..01b32a5 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""검증된 main의 manifest version으로 Git tag와 GitHub Release를 발행한다.""" + +import base64 +import json +import os +import re +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +MANIFEST = "plugins/ennoia/plugin.json" +VERSION = re.compile( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" +) + + +class GitHub: + def __init__(self, repository: str, token: str, api_url: str): + self.url = f"{api_url.rstrip('/')}/repos/{repository}" + self.token = token + + def request(self, path: str, data: dict | None = None): + request = Request( + f"{self.url}/{path}", + data=None if data is None else json.dumps(data).encode(), + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2026-03-10", + "Content-Type": "application/json", + }, + ) + try: + with urlopen(request, timeout=30) as response: + return json.load(response) + except HTTPError as error: + error.close() + # 조회 404만 부재로 취급한다. 인증·rate limit·쓰기 실패는 숨기지 않는다. + if data is None and error.code == 404: + return None + raise RuntimeError(f"GitHub {request.get_method()} {path}: HTTP {error.code}") from None + + +def publish(api: GitHub, version: str, sha: str) -> str: + match = VERSION.fullmatch(version) if isinstance(version, str) else None + if not match or any( + part.isdigit() and len(part) > 1 and part.startswith("0") + for part in (match[4] or "").split(".") + ): + raise ValueError("manifest version은 유효한 SemVer여야 합니다.") + if not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ValueError("검증 대상의 전체 commit SHA가 필요합니다.") + tag = f"v{version}" + main_ref = api.request("git/ref/heads/main") + if not main_ref: + raise RuntimeError("main ref를 조회할 수 없습니다.") + if main_ref["object"]["sha"] != sha: + return "더 최신 main 실행이 있으므로 이전 실행의 발행을 건너뜁니다." + + release = api.request(f"releases/tags/{tag}") + tag_ref = api.request(f"git/ref/tags/{tag}") + if tag_ref: + obj = tag_ref["object"] + # Lightweight tag와 annotated tag 모두 원래 commit을 보존한다. + for _ in range(8): + if obj["type"] != "tag": + break + obj = api.request(f"git/tags/{obj['sha']}")["object"] + if obj["type"] != "commit": + raise RuntimeError(f"{tag}: commit을 가리키는 tag가 아닙니다.") + target = obj["sha"] + if target != sha: + comparison = api.request(f"compare/{target}...{sha}") + if not comparison or comparison["status"] not in {"ahead", "identical"}: + raise RuntimeError(f"{tag}: 현재 main의 이력에 없는 tag입니다.") + content = api.request(f"contents/{MANIFEST}?ref={target}") + if not content or content.get("encoding") != "base64": + raise RuntimeError(f"{tag}: 기존 manifest를 확인할 수 없습니다.") + previous = json.loads(base64.b64decode(content["content"])) + if previous.get("version") != version: + raise RuntimeError(f"{tag}: tag와 manifest version이 다릅니다.") + else: + if release: + raise RuntimeError(f"{tag}: Release는 있지만 tag가 없습니다.") + target = sha + api.request("git/refs", {"ref": f"refs/tags/{tag}", "sha": target}) + + if release: + if release["draft"]: + raise RuntimeError(f"{tag}: 기존 draft Release를 먼저 확인하세요.") + return f"{tag}: 이미 발행되어 변경 없이 종료합니다." + + # tag 생성 뒤 실패해도 다음 실행은 같은 tag에서 Release만 복구한다. + result = api.request("releases", { + "tag_name": tag, + "target_commitish": target, + "name": f"Ennoia Plugin {tag}", + "draft": False, + "prerelease": bool(match[4]), + "generate_release_notes": True, + "make_latest": "legacy", + }) + return f"{tag}: {result['html_url']}" + + +def main() -> None: + if os.environ.get("GITHUB_REF") != "refs/heads/main" or os.environ.get( + "GITHUB_EVENT_NAME" + ) not in {"push", "workflow_dispatch"}: + raise SystemExit("main의 push 또는 workflow_dispatch에서만 발행합니다.") + manifest = Path(__file__).resolve().parents[1] / MANIFEST + api = GitHub( + os.environ["GITHUB_REPOSITORY"], + os.environ["GITHUB_TOKEN"], + os.environ["GITHUB_API_URL"], + ) + message = publish(api, json.loads(manifest.read_text())["version"], os.environ["GITHUB_SHA"]) + print(message) + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary, "a", encoding="utf-8") as output: + output.write(f"{message}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..87923aa --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,172 @@ +import base64 +import json +import unittest +from unittest.mock import Mock, patch +from urllib.error import HTTPError + +from scripts.release import GitHub, MANIFEST, main, publish + + +SHA = "a" * 40 +OLD = "b" * 40 +TAG = "v1.1.0" + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.responses = { + "git/ref/heads/main": {"object": {"sha": SHA}}, + f"releases/tags/{TAG}": None, + f"git/ref/tags/{TAG}": None, + } + self.writes = [] + self.api = Mock(spec=GitHub) + self.api.request.side_effect = self.request + + def request(self, path, data=None): + if data is not None: + self.writes.append((path, data)) + return {"html_url": f"https://github.com/wanteddev/ennoia-plugin/releases/tag/{TAG}"} + return self.responses[path] + + def existing_tag(self, target=SHA, version="1.1.0", annotated=False): + self.responses[f"git/ref/tags/{TAG}"] = { + "object": {"type": "tag" if annotated else "commit", "sha": target} + } + if annotated: + self.responses[f"git/tags/{target}"] = {"object": {"type": "commit", "sha": target}} + self.responses[f"contents/{MANIFEST}?ref={target}"] = { + "encoding": "base64", + "content": base64.b64encode(json.dumps({"version": version}).encode()).decode(), + } + self.responses[f"compare/{target}...{SHA}"] = {"status": "ahead"} + + def test_creates_tag_at_validated_sha_and_published_release(self): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, [ + ("git/refs", {"ref": f"refs/tags/{TAG}", "sha": SHA}), + ("releases", { + "tag_name": TAG, "target_commitish": SHA, "name": f"Ennoia Plugin {TAG}", + "draft": False, "prerelease": False, "generate_release_notes": True, + "make_latest": "legacy", + }), + ]) + + def test_rerun_of_published_version_never_writes(self): + self.existing_tag() + self.responses[f"releases/tags/{TAG}"] = {"draft": False} + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_later_main_commit_with_same_version_preserves_release(self): + self.existing_tag(OLD) + self.responses[f"releases/tags/{TAG}"] = {"draft": False} + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_recovers_release_at_existing_annotated_tag(self): + self.existing_tag(OLD, annotated=True) + publish(self.api, "1.1.0", SHA) + self.assertEqual(len(self.writes), 1) + self.assertEqual(self.writes[0][0], "releases") + self.assertEqual(self.writes[0][1]["target_commitish"], OLD) + + def test_release_failure_leaves_tag_for_next_run(self): + def fail_release(path, data=None): + if path == "releases": + raise RuntimeError("HTTP 503") + result = self.request(path, data) + if path == "git/refs": + self.existing_tag() + return result + + self.api.request.side_effect = fail_release + with self.assertRaisesRegex(RuntimeError, "503"): + publish(self.api, "1.1.0", SHA) + self.api.request.side_effect = self.request + self.writes.clear() + publish(self.api, "1.1.0", SHA) + self.assertEqual([path for path, _ in self.writes], ["releases"]) + + def test_mismatched_tag_version_is_never_overwritten(self): + self.existing_tag(version="1.0.0") + with self.assertRaisesRegex(RuntimeError, "version"): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_tag_outside_main_history_is_rejected(self): + self.existing_tag(OLD) + self.responses[f"compare/{OLD}...{SHA}"] = {"status": "diverged"} + with self.assertRaisesRegex(RuntimeError, "이력"): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_non_commit_tag_is_rejected(self): + self.responses[f"git/ref/tags/{TAG}"] = {"object": {"type": "tree", "sha": SHA}} + with self.assertRaisesRegex(RuntimeError, "commit"): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_draft_is_not_published_or_replaced(self): + self.existing_tag() + self.responses[f"releases/tags/{TAG}"] = {"draft": True} + with self.assertRaisesRegex(RuntimeError, "draft"): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_release_without_tag_is_not_retargeted(self): + self.responses[f"releases/tags/{TAG}"] = {"draft": False} + with self.assertRaisesRegex(RuntimeError, "tag가 없습니다"): + publish(self.api, "1.1.0", SHA) + self.assertEqual(self.writes, []) + + def test_stale_workflow_cannot_publish(self): + self.responses["git/ref/heads/main"] = {"object": {"sha": OLD}} + publish(self.api, "1.1.0", SHA) + self.api.request.assert_called_once_with("git/ref/heads/main") + self.assertEqual(self.writes, []) + + def test_prerelease_and_build_metadata(self): + for version, prerelease in [("1.2.0-rc.1", True), ("1.2.0+build.01", False)]: + with self.subTest(version=version): + self.writes.clear() + self.responses[f"releases/tags/v{version}"] = None + self.responses[f"git/ref/tags/v{version}"] = None + publish(self.api, version, SHA) + self.assertEqual(self.writes[-1][1]["tag_name"], f"v{version}") + self.assertIs(self.writes[-1][1]["prerelease"], prerelease) + + def test_invalid_versions_and_sha_fail_before_api(self): + for version in [None, 11, "v1.1.0", "01.1.0", "1.1.0-01", "1.1.0\n", "../../main"]: + with self.subTest(version=version), self.assertRaises(ValueError): + publish(self.api, version, SHA) + with self.assertRaises(ValueError): + publish(self.api, "1.1.0", "main") + self.api.request.assert_not_called() + + def test_entrypoint_rejects_pull_requests_and_non_main_refs(self): + for ref, event in [("refs/heads/main", "pull_request"), ("refs/tags/v1.1.0", "push"), + ("refs/heads/dev", "workflow_dispatch")]: + with self.subTest(ref=ref, event=event): + with patch.dict("os.environ", {"GITHUB_REF": ref, "GITHUB_EVENT_NAME": event}, clear=True): + with self.assertRaises(SystemExit): + main() + + +class GitHubTests(unittest.TestCase): + def test_only_get_404_means_absent(self): + api = GitHub("wanteddev/ennoia-plugin", "test-token", "https://api.github.com") + for status, data in [(404, None), (401, None), (403, None), (500, None), (404, {}), (422, {})]: + with self.subTest(status=status, data=data): + error = HTTPError("https://api.github.com", status, "error", {}, None) + with patch("scripts.release.urlopen", side_effect=error): + if status == 404 and data is None: + self.assertIsNone(api.request("releases/tags/v1.1.0")) + else: + with self.assertRaisesRegex(RuntimeError, f"HTTP {status}") as caught: + api.request("releases", data) + self.assertNotIn("test-token", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main()