diff --git a/.github/workflows/reusable-release-skill.yaml b/.github/workflows/reusable-release-skill.yaml index 7c853a7..ded5f2d 100644 --- a/.github/workflows/reusable-release-skill.yaml +++ b/.github/workflows/reusable-release-skill.yaml @@ -115,6 +115,12 @@ jobs: " 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')" \ + 'for market_doc in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md"):' \ + ' src = os.path.abspath(market_doc)' \ + ' dst = os.path.join(PACKAGE, market_doc)' \ + ' if os.path.isfile(src):' \ + ' shutil.copy2(src, dst)' \ + " print(f'Copied {market_doc}')" \ "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)" \ @@ -144,49 +150,119 @@ jobs: - name: Parse Metadata and Pack id: build_task run: | - python3.12 -c " - import frontmatter, os, json, shutil - post = frontmatter.load('SKILL.md') + cat > /tmp/parse_skill_release_metadata.py <<'PY' + import frontmatter + import json + import os + import re + import shutil + + + def load_md(path: str): + post = frontmatter.load(path) + meta = post.metadata or {} + body = (post.content or "").strip() + return meta if isinstance(meta, dict) else {}, body + + + def extract_changelog_for_version(text: str, version: str) -> str | None: + """Extract Keep-a-Changelog / plain ## version section; None if not found.""" + if not text or not version: + return None + ver = re.escape(version.lstrip("vV")) + pattern = re.compile( + rf"^##\s*\[?v?{ver}\]?([^\n]*)$", + re.MULTILINE | re.IGNORECASE, + ) + match = pattern.search(text) + if not match: + return None + start = match.end() + next_h2 = re.search(r"^##\s+", text[start:], re.MULTILINE) + end = start + next_h2.start() if next_h2 else len(text) + return text[start:end].strip() + + + 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): + skill_readme_md = (post.content or "").strip() + skill_description = metadata.get("description") + metadata["readme_md"] = skill_readme_md + + if os.path.isfile("README.md"): try: - readme_post = frontmatter.load(readme_path) - body = (readme_post.content or '').strip() - rm_meta = readme_post.metadata or {} - desc = rm_meta.get('description') + rm_meta, body = load_md("README.md") + desc = rm_meta.get("description") if desc is not None and str(desc).strip(): - metadata['description'] = str(desc).strip() + metadata["description"] = str(desc).strip() if body: - metadata['readme_md'] = 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 (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 + + # Market tabs: only set keys when files exist (backend updates only present keys). + if os.path.isfile("TUTORIAL.md"): + try: + _tm_meta, tutorial_body = load_md("TUTORIAL.md") + metadata["tutorial_md"] = tutorial_body + except Exception as exc: + raise RuntimeError(f"Failed to parse TUTORIAL.md: {exc}") from exc + + if os.path.isfile("DEMO.md"): + try: + demo_meta, demo_body = load_md("DEMO.md") + metadata["demo_md"] = demo_body + url = demo_meta.get("demo_video_url") + metadata["demo_video_url"] = ( + str(url).strip() if url is not None and str(url).strip() else "" + ) + except Exception as exc: + raise RuntimeError(f"Failed to parse DEMO.md: {exc}") from exc + + openclaw_meta = metadata.get("metadata", {}).get("openclaw", {}) + if not isinstance(openclaw_meta, dict): + openclaw_meta = {} + 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') - " + 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 + + if os.path.isfile("CHANGELOG.md"): + try: + with open("CHANGELOG.md", encoding="utf-8") as fh: + changelog_text = fh.read() + section = extract_changelog_for_version(changelog_text, version) + if section is not None: + metadata["changelog"] = section + except Exception as exc: + raise RuntimeError(f"Failed to parse CHANGELOG.md: {exc}") from exc + + artifact_platform = (os.environ.get("ARTIFACT_PLATFORM") or "windows").strip() + zip_base = f"{slug}-{artifact_platform}" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") 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") + PY + python3.12 /tmp/parse_skill_release_metadata.py - name: Sync Database env: diff --git a/pyproject.toml b/pyproject.toml index b21d9f9..b5552a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jiangchang-platform-kit" -version = "1.2.0" +version = "1.2.1" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 48b8769..96e5cab 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -39,9 +39,23 @@ def test_workflow_packages_readme_at_root(workflow_text: str) -> None: def test_workflow_metadata_reads_root_readme(workflow_text: str) -> None: - assert "readme_path = 'README.md'" in workflow_text - assert "frontmatter.load(readme_path)" in workflow_text - assert "metadata['readme_md'] = body" in workflow_text + assert 'load_md("README.md")' in workflow_text + assert 'metadata["readme_md"] = body' in workflow_text + + +def test_workflow_packages_market_docs_when_present(workflow_text: str) -> None: + assert 'for market_doc in ("TUTORIAL.md", "DEMO.md", "CHANGELOG.md"):' in workflow_text + assert "Copied {market_doc}" in workflow_text + + +def test_workflow_metadata_syncs_tutorial_demo_changelog(workflow_text: str) -> None: + assert 'load_md("TUTORIAL.md")' in workflow_text + assert 'metadata["tutorial_md"] = tutorial_body' in workflow_text + assert 'load_md("DEMO.md")' in workflow_text + assert 'metadata["demo_md"] = demo_body' in workflow_text + assert 'metadata["demo_video_url"]' in workflow_text + assert "extract_changelog_for_version" in workflow_text + assert 'metadata["changelog"] = section' in workflow_text def test_workflow_does_not_use_references_readme_for_marketplace(