name: Reusable Skill Release on: workflow_call: inputs: runs_on: description: "Runner label; must match a registered runner (use host runner for pip/python on same machine as Node frontend)" required: false type: string default: ubuntu-latest artifact_platform: required: false type: string default: windows pyarmor_platform: required: false type: string default: windows.x86_64 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 secrets: # Base64 of official pyarmor-regfile-*.zip — optional; without it, PyArmor trial applies (e.g. large per-file code limits). PYARMOR_REG_B64: required: false jobs: build-and-deploy: runs-on: ${{ inputs.runs_on }} defaults: run: shell: bash env: ARTIFACT_PLATFORM: ${{ inputs.artifact_platform }} PYARMOR_PLATFORM: ${{ inputs.pyarmor_platform }} PIP_BREAK_SYSTEM_PACKAGES: "1" # Prefer self-built Python 3.12 under /usr/local (Alibaba Cloud Linux host); keep system paths as fallback. # 显式前缀,避免部分 Runner 未注入 env.PATH 时丢失系统路径 PATH: /usr/local/bin:/usr/local/python3.12/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin # PyArmor 交叉平台加密时会内部执行 pip 安装 pyarmor.cli.core.* 等包;不设则默认走 files.pythonhosted.org,国内 CI 易超时。 PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple PIP_EXTRA_INDEX_URL: https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://mirrors.huaweicloud.com/repository/pypi/simple PIP_DEFAULT_TIMEOUT: "180" PIP_TRUSTED_HOST: pypi.tuna.tsinghua.edu.cn mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.huaweicloud.com files.pythonhosted.org pypi.org steps: - uses: https://git.jc2009.com/admin/actions-checkout@v4 # Pin PyArmor 8.5.3 — matches desktop bundles; 9.x trial is stricter in CI。 # 镜像由 job env(PIP_INDEX_URL / PIP_EXTRA_INDEX_URL)统一指定,与 Encrypt 步骤中 PyArmor 内部 pip 一致。 # 使用 python3.12 -m pip,避免仅存在 python3(3.6) 或裸 pip 不在 PATH 的宿主机/容器。 - name: Setup Tools run: python3.12 -m pip install "pyarmor==8.5.3" requests python-frontmatter --break-system-packages - name: Register PyArmor (optional) env: PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }} run: | if [ -z "${PYARMOR_REG_B64}" ]; then echo "PyArmor: no PYARMOR_REG_B64 secret — trial mode (very large single .py modules may fail to obfuscate)." else python3.12 -c "import os,base64,pathlib,subprocess; os.environ['PATH']='/usr/local/bin:/usr/local/python3.12/bin:'+os.environ.get('PATH',''); p=pathlib.Path('/tmp/pyarmor-reg.zip'); p.write_bytes(base64.standard_b64decode(os.environ['PYARMOR_REG_B64'])); subprocess.run(['pyarmor','reg',str(p)],check=True); p.unlink(missing_ok=True)" fi # 递归加密整个 scripts/(含 cli、service、db、util 等子包);产物保留与源码一致的 scripts/ 层级,入口为 scripts/main.py。 - name: Encrypt Source Code run: | export PATH="/usr/local/bin:/usr/local/python3.12/bin:${PATH:-}" mkdir -p dist/package/scripts set -euo pipefail test -d scripts ( cd scripts && pyarmor gen --platform "${PYARMOR_PLATFORM}" -r -O ../dist/package/scripts . ) cp SKILL.md dist/package/ if [ -d references ]; then cp -r references dist/package/; fi if [ -d assets ]; then cp -r assets dist/package/; fi - name: Parse Metadata and Pack id: build_task run: | python3.12 -c " import frontmatter, os, json, shutil post = frontmatter.load('SKILL.md') metadata = dict(post.metadata or {}) skill_readme_md = (post.content or '').strip() skill_description = metadata.get('description') metadata['readme_md'] = skill_readme_md readme_path = os.path.join('references', 'README.md') if os.path.isfile(readme_path): try: readme_post = frontmatter.load(readme_path) body = (readme_post.content or '').strip() rm_meta = readme_post.metadata or {} desc = rm_meta.get('description') if desc is not None and str(desc).strip(): metadata['description'] = str(desc).strip() if body: metadata['readme_md'] = body except Exception: pass if not (metadata.get('readme_md') or '').strip(): metadata['readme_md'] = skill_readme_md if skill_description is not None and not str(metadata.get('description', '') or '').strip(): metadata['description'] = skill_description 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: | python3.12 -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: | python3.12 -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: | python3.12 -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) "