From 117d31298e94f20eee3f3a39d425313680d4e021 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Mon, 30 Mar 2026 18:49:29 +0800 Subject: [PATCH] feat: add jiangchang platform kit skeleton Add a shared SDK scaffold for entitlement checks and a reusable Gitea release workflow template to standardize skill packaging and publishing across projects. --- .github/workflows/reusable-release-skill.yaml | 146 ++++++++++++++++++ README.md | 7 + .../workflows/use-reusable-release-skill.yaml | 12 ++ sdk/jiangchang_skill_core/__init__.py | 9 ++ sdk/jiangchang_skill_core/client.py | 63 ++++++++ sdk/jiangchang_skill_core/errors.py | 10 ++ sdk/jiangchang_skill_core/guard.py | 22 +++ sdk/jiangchang_skill_core/models.py | 10 ++ sdk/pyproject.toml | 19 +++ 9 files changed, 298 insertions(+) create mode 100644 .github/workflows/reusable-release-skill.yaml create mode 100644 README.md create mode 100644 examples/workflows/use-reusable-release-skill.yaml create mode 100644 sdk/jiangchang_skill_core/__init__.py create mode 100644 sdk/jiangchang_skill_core/client.py create mode 100644 sdk/jiangchang_skill_core/errors.py create mode 100644 sdk/jiangchang_skill_core/guard.py create mode 100644 sdk/jiangchang_skill_core/models.py create mode 100644 sdk/pyproject.toml diff --git a/.github/workflows/reusable-release-skill.yaml b/.github/workflows/reusable-release-skill.yaml new file mode 100644 index 0000000..a1c634b --- /dev/null +++ b/.github/workflows/reusable-release-skill.yaml @@ -0,0 +1,146 @@ +name: Reusable Skill Release + +on: + workflow_call: + inputs: + artifact_platform: + required: false + type: string + default: windows + pyarmor_platform: + required: false + type: string + default: windows.x86_64 + include_readme_md: + required: false + type: boolean + default: false + upload_url: + required: false + type: string + default: https://jc2009.com/api/upload + sync_url: + required: false + type: string + default: https://jc2009.com/api/skill/update + prune_url: + required: false + type: string + default: https://jc2009.com/api/artifacts/prune-old-versions + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + ARTIFACT_PLATFORM: ${{ inputs.artifact_platform }} + PYARMOR_PLATFORM: ${{ inputs.pyarmor_platform }} + PIP_BREAK_SYSTEM_PACKAGES: "1" + steps: + - uses: http://120.25.191.12:3000/admin/actions-checkout@v4 + + - name: Setup Tools + run: pip install "pyarmor>=8.5" requests python-frontmatter --break-system-packages -i https://pypi.tuna.tsinghua.edu.cn/simple + + - name: Encrypt Source Code + run: | + mkdir -p dist/package + pyarmor gen --platform "${PYARMOR_PLATFORM}" -O dist/package scripts/*.py + cp SKILL.md dist/package/ + + - name: Parse Metadata and Pack + id: build_task + env: + INCLUDE_README_MD: ${{ inputs.include_readme_md }} + run: | + python -c " + import frontmatter, os, json, shutil + post = frontmatter.load('SKILL.md') + metadata = dict(post.metadata or {}) + if os.environ.get('INCLUDE_README_MD', 'false').lower() == 'true': + metadata['readme_md'] = (post.content or '').strip() + openclaw_meta = metadata.get('metadata', {}).get('openclaw', {}) + slug = (openclaw_meta.get('slug') or metadata.get('slug') or metadata.get('name') or '').strip() + if not slug: + raise Exception('SKILL.md 缺少 slug/name') + ref_name = (os.environ.get('GITHUB_REF_NAME') or '').strip() + if not ref_name.startswith('v'): + raise Exception(f'非法标签: {ref_name}') + version = ref_name.lstrip('v') + metadata['version'] = version + artifact_platform = (os.environ.get('ARTIFACT_PLATFORM') or 'windows').strip() + zip_base = f'{slug}-{artifact_platform}' + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'slug={slug}\n') + f.write(f'version={version}\n') + f.write(f'zip_base={zip_base}\n') + f.write(f'artifact_platform={artifact_platform}\n') + f.write(f'metadata={json.dumps(metadata, ensure_ascii=False)}\n') + shutil.make_archive(zip_base, 'zip', 'dist/package') + " + + - name: Sync Database + env: + METADATA_JSON: ${{ steps.build_task.outputs.metadata }} + SYNC_URL: ${{ inputs.sync_url }} + run: | + python -c " + import requests, json, os + metadata = json.loads(os.environ['METADATA_JSON']) + res = requests.post(os.environ['SYNC_URL'], json=metadata) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " + + - name: Upload Encrypted ZIP + env: + SLUG: ${{ steps.build_task.outputs.slug }} + VERSION: ${{ steps.build_task.outputs.version }} + ZIP_BASE: ${{ steps.build_task.outputs.zip_base }} + ARTIFACT_PLATFORM: ${{ steps.build_task.outputs.artifact_platform }} + UPLOAD_URL: ${{ inputs.upload_url }} + run: | + python -c " + import requests, os + slug = os.environ['SLUG'] + version = os.environ['VERSION'] + zip_path = f\"{os.environ['ZIP_BASE']}.zip\" + payload = { + 'plugin_name': slug, + 'version': version, + 'artifact_type': 'skill', + 'artifact_platform': os.environ.get('ARTIFACT_PLATFORM', 'windows'), + } + filename = os.path.basename(zip_path) + with open(zip_path, 'rb') as f: + res = requests.post(os.environ['UPLOAD_URL'], data=payload, files={'file': (filename, f)}) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " + + - name: Prune Old Versions + env: + SLUG: ${{ steps.build_task.outputs.slug }} + VERSION: ${{ steps.build_task.outputs.version }} + PRUNE_URL: ${{ inputs.prune_url }} + run: | + python -c " + import requests, os + payload = { + 'name': os.environ['SLUG'], + 'artifact_type': 'skill', + 'keep_count': 1, + 'protect_version': os.environ['VERSION'] + } + res = requests.post(os.environ['PRUNE_URL'], json=payload) + if res.status_code != 200: + exit(1) + body = res.json() + if body.get('code') != 200: + exit(1) + " diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d5b553 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# jiangchang-platform-kit + +Shared platform components for Jiangchang skills: + +- `sdk/jiangchang_skill_core`: entitlement SDK package. +- `.github/workflows/reusable-release-skill.yaml`: reusable CI release workflow. +- `examples/workflows/use-reusable-release-skill.yaml`: caller workflow sample. diff --git a/examples/workflows/use-reusable-release-skill.yaml b/examples/workflows/use-reusable-release-skill.yaml new file mode 100644 index 0000000..016d6b6 --- /dev/null +++ b/examples/workflows/use-reusable-release-skill.yaml @@ -0,0 +1,12 @@ +name: Skill Release +on: + push: + tags: ["v*"] + +jobs: + release: + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@v0.1.0 + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 + include_readme_md: false diff --git a/sdk/jiangchang_skill_core/__init__.py b/sdk/jiangchang_skill_core/__init__.py new file mode 100644 index 0000000..c49320b --- /dev/null +++ b/sdk/jiangchang_skill_core/__init__.py @@ -0,0 +1,9 @@ +from .client import EntitlementClient +from .guard import enforce_entitlement +from .models import EntitlementResult + +__all__ = [ + "EntitlementClient", + "EntitlementResult", + "enforce_entitlement", +] diff --git a/sdk/jiangchang_skill_core/client.py b/sdk/jiangchang_skill_core/client.py new file mode 100644 index 0000000..6860557 --- /dev/null +++ b/sdk/jiangchang_skill_core/client.py @@ -0,0 +1,63 @@ +import os +from typing import Any + +import requests + +from .errors import EntitlementServiceError +from .models import EntitlementResult + + +class EntitlementClient: + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + timeout_seconds: int | None = None, + ) -> None: + self.base_url = (base_url or os.getenv("JIANGCHANG_AUTH_BASE_URL", "")).rstrip("/") + self.api_key = api_key or os.getenv("JIANGCHANG_AUTH_API_KEY", "") + self.timeout_seconds = timeout_seconds or int( + os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS", "5") + ) + if not self.base_url: + raise EntitlementServiceError("missing JIANGCHANG_AUTH_BASE_URL") + + def check_entitlement( + self, + user_id: str, + skill_slug: str, + trace_id: str = "", + context: dict[str, Any] | None = None, + ) -> EntitlementResult: + url = f"{self.base_url}/api/entitlements/check" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": trace_id, + "context": context or {}, + } + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + try: + res = requests.post( + url, + json=payload, + headers=headers, + timeout=self.timeout_seconds, + ) + except requests.RequestException as exc: + raise EntitlementServiceError(f"entitlement request failed: {exc}") from exc + + if res.status_code != 200: + raise EntitlementServiceError(f"entitlement http status {res.status_code}") + try: + body = res.json() + except ValueError as exc: + raise EntitlementServiceError("entitlement response is not json") from exc + + data = body.get("data") or {} + allow = bool(data.get("allow", False)) + reason = str(data.get("reason") or body.get("msg") or "") + expire_at = str(data.get("expire_at") or "") + return EntitlementResult(allow=allow, reason=reason, expire_at=expire_at, raw=body) diff --git a/sdk/jiangchang_skill_core/errors.py b/sdk/jiangchang_skill_core/errors.py new file mode 100644 index 0000000..7aa9b82 --- /dev/null +++ b/sdk/jiangchang_skill_core/errors.py @@ -0,0 +1,10 @@ +class EntitlementError(Exception): + pass + + +class EntitlementDeniedError(EntitlementError): + pass + + +class EntitlementServiceError(EntitlementError): + pass diff --git a/sdk/jiangchang_skill_core/guard.py b/sdk/jiangchang_skill_core/guard.py new file mode 100644 index 0000000..365d745 --- /dev/null +++ b/sdk/jiangchang_skill_core/guard.py @@ -0,0 +1,22 @@ +from .client import EntitlementClient +from .errors import EntitlementDeniedError +from .models import EntitlementResult + + +def enforce_entitlement( + user_id: str, + skill_slug: str, + trace_id: str = "", + context: dict | None = None, + client: EntitlementClient | None = None, +) -> EntitlementResult: + c = client or EntitlementClient() + result = c.check_entitlement( + user_id=user_id, + skill_slug=skill_slug, + trace_id=trace_id, + context=context or {}, + ) + if not result.allow: + raise EntitlementDeniedError(result.reason or "skill not purchased or expired") + return result diff --git a/sdk/jiangchang_skill_core/models.py b/sdk/jiangchang_skill_core/models.py new file mode 100644 index 0000000..473a70d --- /dev/null +++ b/sdk/jiangchang_skill_core/models.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class EntitlementResult: + allow: bool + reason: str = "" + expire_at: str = "" + raw: dict[str, Any] | None = None diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml new file mode 100644 index 0000000..854fc10 --- /dev/null +++ b/sdk/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "jiangchang-skill-core" +version = "0.1.0" +description = "Common entitlement SDK for Jiangchang skills" +requires-python = ">=3.10" +dependencies = [ + "requests>=2.31.0", +] + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] +include = ["jiangchang_skill_core*"]