Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dc46b0555 | |||
| 54b75c641e |
148
.github/workflows/reusable-release-skill.yaml
vendored
148
.github/workflows/reusable-release-skill.yaml
vendored
@@ -115,6 +115,12 @@ jobs:
|
|||||||
" print('Copied README.md')" \
|
" print('Copied README.md')" \
|
||||||
'if os.path.isfile(readme_src) and not os.path.isfile(readme_dst):' \
|
'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')" \
|
" 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('references', os.path.join(PACKAGE, 'references'), references_ignore)" \
|
||||||
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
"copy_dir('assets', os.path.join(PACKAGE, 'assets'), common_ignore)" \
|
||||||
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
|
"copy_dir('tests', os.path.join(PACKAGE, 'tests'), common_ignore)" \
|
||||||
@@ -144,49 +150,119 @@ jobs:
|
|||||||
- name: Parse Metadata and Pack
|
- name: Parse Metadata and Pack
|
||||||
id: build_task
|
id: build_task
|
||||||
run: |
|
run: |
|
||||||
python3.12 -c "
|
cat > /tmp/parse_skill_release_metadata.py <<'PY'
|
||||||
import frontmatter, os, json, shutil
|
import frontmatter
|
||||||
post = frontmatter.load('SKILL.md')
|
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 {})
|
metadata = dict(post.metadata or {})
|
||||||
skill_readme_md = (post.content or '').strip()
|
skill_readme_md = (post.content or "").strip()
|
||||||
skill_description = metadata.get('description')
|
skill_description = metadata.get("description")
|
||||||
metadata['readme_md'] = skill_readme_md
|
metadata["readme_md"] = skill_readme_md
|
||||||
readme_path = 'README.md'
|
|
||||||
if os.path.isfile(readme_path):
|
if os.path.isfile("README.md"):
|
||||||
try:
|
try:
|
||||||
readme_post = frontmatter.load(readme_path)
|
rm_meta, body = load_md("README.md")
|
||||||
body = (readme_post.content or '').strip()
|
desc = rm_meta.get("description")
|
||||||
rm_meta = readme_post.metadata or {}
|
|
||||||
desc = rm_meta.get('description')
|
|
||||||
if desc is not None and str(desc).strip():
|
if desc is not None and str(desc).strip():
|
||||||
metadata['description'] = str(desc).strip()
|
metadata["description"] = str(desc).strip()
|
||||||
if body:
|
if body:
|
||||||
metadata['readme_md'] = body
|
metadata["readme_md"] = body
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not (metadata.get('readme_md') or '').strip():
|
|
||||||
metadata['readme_md'] = skill_readme_md
|
if not (metadata.get("readme_md") or "").strip():
|
||||||
if skill_description is not None and not str(metadata.get('description', '') or '').strip():
|
metadata["readme_md"] = skill_readme_md
|
||||||
metadata['description'] = skill_description
|
if skill_description is not None and not str(metadata.get("description", "") or "").strip():
|
||||||
openclaw_meta = metadata.get('metadata', {}).get('openclaw', {})
|
metadata["description"] = skill_description
|
||||||
slug = (openclaw_meta.get('slug') or metadata.get('slug') or metadata.get('name') or '').strip()
|
|
||||||
|
# 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:
|
if not slug:
|
||||||
raise Exception('SKILL.md 缺少 slug/name')
|
raise Exception("SKILL.md 缺少 slug/name")
|
||||||
ref_name = (os.environ.get('GITHUB_REF_NAME') or '').strip()
|
|
||||||
if not ref_name.startswith('v'):
|
ref_name = (os.environ.get("GITHUB_REF_NAME") or "").strip()
|
||||||
raise Exception(f'非法标签: {ref_name}')
|
if not ref_name.startswith("v"):
|
||||||
version = ref_name.lstrip('v')
|
raise Exception(f"非法标签: {ref_name}")
|
||||||
metadata['version'] = version
|
version = ref_name.lstrip("v")
|
||||||
artifact_platform = (os.environ.get('ARTIFACT_PLATFORM') or 'windows').strip()
|
metadata["version"] = version
|
||||||
zip_base = f'{slug}-{artifact_platform}'
|
|
||||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
|
if os.path.isfile("CHANGELOG.md"):
|
||||||
f.write(f'slug={slug}\n')
|
try:
|
||||||
f.write(f'version={version}\n')
|
with open("CHANGELOG.md", encoding="utf-8") as fh:
|
||||||
f.write(f'zip_base={zip_base}\n')
|
changelog_text = fh.read()
|
||||||
f.write(f'artifact_platform={artifact_platform}\n')
|
section = extract_changelog_for_version(changelog_text, version)
|
||||||
f.write(f'metadata={json.dumps(metadata, ensure_ascii=False)}\n')
|
if section is not None:
|
||||||
shutil.make_archive(zip_base, 'zip', 'dist/package')
|
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
|
- name: Sync Database
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jiangchang-platform-kit"
|
name = "jiangchang-platform-kit"
|
||||||
version = "1.2.0"
|
version = "1.2.2"
|
||||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,6 +16,29 @@ _user_env_cache: dict[str, str] | None = None
|
|||||||
_example_defaults_cache: dict[str, str] | None = None
|
_example_defaults_cache: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_value(raw_value: str) -> str:
|
||||||
|
"""Parse one .env value: strip quotes / trailing comments; empty+# → \"\"."""
|
||||||
|
value = raw_value if raw_value is not None else ""
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Quoted values keep interior content (including #).
|
||||||
|
if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in ('"', "'"):
|
||||||
|
return stripped[1:-1]
|
||||||
|
|
||||||
|
# Unquoted: ``KEY= # comment`` or ``KEY=# comment`` must be empty, not the comment text.
|
||||||
|
# Also strip ``KEY=value # comment``. Keep ``KEY=https://x/#y`` (no whitespace before #).
|
||||||
|
if re.search(r"(^|\s)#", value):
|
||||||
|
# Split at first whitespace-#-comment or whole-value comment.
|
||||||
|
m = re.match(r"^(.*?)\s+#.*$", value)
|
||||||
|
if m is not None:
|
||||||
|
return m.group(1).strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
return ""
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
def _parse_env_file(path: str) -> dict[str, str]:
|
def _parse_env_file(path: str) -> dict[str, str]:
|
||||||
"""标准库手写 .env 解析(不引 python-dotenv)。"""
|
"""标准库手写 .env 解析(不引 python-dotenv)。"""
|
||||||
result: dict[str, str] = {}
|
result: dict[str, str] = {}
|
||||||
@@ -29,14 +53,9 @@ def _parse_env_file(path: str) -> dict[str, str]:
|
|||||||
continue
|
continue
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
key = key.strip()
|
key = key.strip()
|
||||||
value = value.strip()
|
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
result[key] = _parse_env_value(value)
|
||||||
value = value[1:-1]
|
|
||||||
elif " #" in value:
|
|
||||||
value = value.split(" #", 1)[0].strip()
|
|
||||||
result[key] = value
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,49 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertEqual(config.get_float("BAD", 9.9), 9.9)
|
self.assertEqual(config.get_float("BAD", 9.9), 9.9)
|
||||||
self.assertEqual(config.get_int("BAD", 7), 7)
|
self.assertEqual(config.get_int("BAD", 7), 7)
|
||||||
|
|
||||||
|
def test_parse_empty_value_with_inline_comment(self) -> None:
|
||||||
|
"""Regression: ``KEY= # comment`` must be empty, not ``# comment``."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
with open(example, "w", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
"DEFAULT_ACCOUNT_ID= # 可填账号管理中的编号\n"
|
||||||
|
"DEFAULT_LOGIN_ID=# 只填登录名\n"
|
||||||
|
"HAS_VALUE=real # trailing note\n"
|
||||||
|
"URL=https://example.com/path#frag\n"
|
||||||
|
"QUOTED=\"keep # hash\"\n"
|
||||||
|
"EMPTY=\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||||
|
os.environ["JIANGCHANG_USER_ID"] = "u1"
|
||||||
|
# Clear process overrides for these keys if any
|
||||||
|
for k in (
|
||||||
|
"DEFAULT_ACCOUNT_ID",
|
||||||
|
"DEFAULT_LOGIN_ID",
|
||||||
|
"HAS_VALUE",
|
||||||
|
"URL",
|
||||||
|
"QUOTED",
|
||||||
|
"EMPTY",
|
||||||
|
):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
dest = config.ensure_env_file("sk-comment", example)
|
||||||
|
config.reset_cache()
|
||||||
|
|
||||||
|
parsed = config._parse_env_file(dest)
|
||||||
|
self.assertEqual(parsed.get("DEFAULT_ACCOUNT_ID"), "")
|
||||||
|
self.assertEqual(parsed.get("DEFAULT_LOGIN_ID"), "")
|
||||||
|
self.assertEqual(parsed.get("HAS_VALUE"), "real")
|
||||||
|
self.assertEqual(parsed.get("URL"), "https://example.com/path#frag")
|
||||||
|
self.assertEqual(parsed.get("QUOTED"), "keep # hash")
|
||||||
|
self.assertEqual(parsed.get("EMPTY"), "")
|
||||||
|
|
||||||
|
# Empty example/user values must not surface as fake non-empty defaults.
|
||||||
|
self.assertIsNone(config.get("DEFAULT_ACCOUNT_ID"))
|
||||||
|
self.assertIsNone(config.get("DEFAULT_LOGIN_ID"))
|
||||||
|
self.assertEqual(config.get("HAS_VALUE"), "real")
|
||||||
|
|
||||||
def test_merge_missing_env_keys_appends_only_new(self) -> None:
|
def test_merge_missing_env_keys_appends_only_new(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
example = os.path.join(tmp, ".env.example")
|
example = os.path.join(tmp, ".env.example")
|
||||||
|
|||||||
@@ -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:
|
def test_workflow_metadata_reads_root_readme(workflow_text: str) -> None:
|
||||||
assert "readme_path = 'README.md'" in workflow_text
|
assert 'load_md("README.md")' in workflow_text
|
||||||
assert "frontmatter.load(readme_path)" in workflow_text
|
assert 'metadata["readme_md"] = body' 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(
|
def test_workflow_does_not_use_references_readme_for_marketplace(
|
||||||
|
|||||||
Reference in New Issue
Block a user