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
22 changes: 22 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 태그 및 GitHub Release를 확인합니다. 같은 버전은 재발행하지 않으며, 실패 복구는 해당 workflow를 `main`에서 수동 실행합니다. 태그를 강제로 이동하지 않습니다.
4. 두 앱·두 CLI에서 repo 동기화/업데이트 후 설치·새 세션 Skill 발견·OAuth와 연결 source·직접 App/SuperApp 업무 상태를 각각 확인합니다. 수동 MCP가 공존할 때 같은 이름 도구의 성공만으로 Plugin 전용 연결이라 주장하지 않습니다.
5. 검증한 client 버전, repo commit, MCP 환경, 완료 범위와 제한을 `docs/compatibility.md`에 기록합니다.

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 태그와 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 개선은 별도로 확인해야 합니다.
128 changes: 128 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -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()
172 changes: 172 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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()
Loading