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 # Obfuscate scripts recursively. Copy package support files as plain text. - 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 . ) printf '%s\n' \ 'import os' \ 'import shutil' \ '' \ "PACKAGE = 'dist/package'" \ '' \ 'def common_ignore(dir_path, names):' \ ' ignored = set()' \ ' for name in names:' \ ' lower = name.lower()' \ " if name in ('__pycache__', '.pytest_cache', 'artifacts'):" \ ' ignored.add(name)' \ " elif lower.endswith('.pyc') or lower.endswith('.pyo') or lower.endswith('.db'):" \ ' ignored.add(name)' \ ' return list(ignored)' \ '' \ "ref_src = os.path.abspath('references')" \ '' \ 'def references_ignore(dir_path, names):' \ ' ignored = set(common_ignore(dir_path, names))' \ " if os.path.abspath(dir_path) == ref_src and 'REQUIREMENTS.md' in names:" \ " ignored.add('REQUIREMENTS.md')" \ ' return list(ignored)' \ '' \ 'def copy_dir(src, dst, ignore_fn):' \ ' if os.path.isdir(src):' \ ' shutil.copytree(src, dst, ignore=ignore_fn, dirs_exist_ok=True)' \ '' \ "shutil.copy2('SKILL.md', os.path.join(PACKAGE, 'SKILL.md'))" \ "readme_src = os.path.abspath('README.md')" \ "readme_dst = os.path.join(PACKAGE, 'README.md')" \ 'if os.path.isfile(readme_src):' \ " shutil.copy2(readme_src, readme_dst)" \ " print('Copied README.md')" \ 'if os.path.isfile(readme_src) and not os.path.isfile(readme_dst):' \ " raise RuntimeError('README.md exists in skill root but was not packaged')" \ "copy_dir('references', os.path.join(PACKAGE, 'references'), references_ignore)" \ "copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \ "copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \ "copy_dir('evals', os.path.join(PACKAGE, 'evals'), common_ignore)" \ '' \ "req_src = os.path.abspath('requirements.txt')" \ "req_dst = os.path.join(PACKAGE, 'requirements.txt')" \ 'if os.path.isfile(req_src):' \ " shutil.copy2(req_src, req_dst)" \ " print('Copied requirements.txt')" \ '' \ "env_example_src = os.path.abspath('.env.example')" \ "env_example_dst = os.path.join(PACKAGE, '.env.example')" \ 'if os.path.isfile(env_example_src):' \ " shutil.copy2(env_example_src, env_example_dst)" \ " print('Copied .env.example')" \ 'if os.path.isfile(env_example_src) and not os.path.isfile(env_example_dst):' \ " raise RuntimeError('.env.example exists in skill root but was not packaged')" \ '' \ "print('Package top-level entries:')" \ 'for name in sorted(os.listdir(PACKAGE)):' \ " print(' -', name)" \ > /tmp/package_skill_release_copy.py python3.12 /tmp/package_skill_release_copy.py - 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 = '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) "