ci: publish formal version from pyproject.toml on main push
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 25s

Remove .postN CI versioning, fix runtime version_ge for legacy post releases, and use ASCII issue separators in health output.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 09:26:41 +08:00
parent c9398451d0
commit 36a411810d
7 changed files with 91 additions and 109 deletions

View File

@@ -22,13 +22,6 @@ jobs:
steps: steps:
- uses: https://git.jc2009.com/admin/actions-checkout@v4 - uses: https://git.jc2009.com/admin/actions-checkout@v4
- name: Resolve and patch package version
env:
GITHUB_REF: ${{ github.ref }}
GITHUB_RUN_NUMBER: ${{ github.run_number }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: python3.12 tools/ci_set_package_version.py
- name: Install build tools - name: Install build tools
run: python3.12 -m pip install --upgrade build twine --break-system-packages run: python3.12 -m pip install --upgrade build twine --break-system-packages

View File

@@ -142,17 +142,18 @@ run_screencast(
## 5. 发版流程(维护者) ## 5. 发版流程(维护者)
### main 分支自动预发布 ### 发布规则
每次推送到 `main` 会触发 `.github/workflows/publish.yml`,自动构建并发布 **post 预发布版本**(仅供 CI 流水线内部使用,**不是**正式版本线) - jiangchang-platform-kit **只发布正式版本**
- 每次需要发布公共能力时,先 bump `pyproject.toml``version`(例如 `1.0.10``1.0.11`)。
正式版本线使用 **1.0.x** semver通过 git tag 发布 - 推送 `main` 后,`.github/workflows/publish.yml` 会构建并上传该正式版本到 Gitea PyPI
- 发布包版本必须等于 `pyproject.toml` 中的 `version`;若该版本已存在,上传会失败,需继续 bump 版本号后再推送。
```bash ```bash
git push origin main git push origin main
``` ```
测试人员升级安装(始终安装当前正式 tag 对应的版本,例如 `1.0.9` 升级安装
```bash ```bash
python -m pip install --upgrade \ python -m pip install --upgrade \
@@ -161,15 +162,6 @@ python -m pip install --upgrade \
jiangchang-platform-kit jiangchang-platform-kit
``` ```
### 正式版本tag
打 tag 并推送后,发布 **去掉 `v` 前缀** 的正式版本号(例如 `v1.0.9` → PyPI 包 `1.0.9`
```bash
git tag v1.0.9
git push origin v1.0.9
```
### 安装后验证 ### 安装后验证
```bash ```bash

View File

@@ -23,7 +23,7 @@ __all__ = [
"LaunchError", "LaunchError",
"GatewayDownError", "GatewayDownError",
] ]
__version__ = "1.0.9" __version__ = "1.0.11"
def __getattr__(name: str): def __getattr__(name: str):

View File

@@ -71,9 +71,23 @@ def _resolve_data_root(env: Mapping[str, str]) -> str:
return platform_default_data_root() return platform_default_data_root()
def _parse_version(version: str) -> tuple[int, ...]: def _parse_version(version: str) -> tuple[int, int, int, int]:
"""Parse major.minor.patch[.postN] into (major, minor, patch, post)."""
text = version.strip()
post = 0
if ".post" in text:
base, _, post_part = text.partition(".post")
post_digits = ""
for ch in post_part:
if ch.isdigit():
post_digits += ch
else:
break
post = int(post_digits) if post_digits else 0
text = base
parts: list[int] = [] parts: list[int] = []
for piece in version.strip().split("."): for piece in text.split("."):
digits = "" digits = ""
for ch in piece: for ch in piece:
if ch.isdigit(): if ch.isdigit():
@@ -82,11 +96,15 @@ def _parse_version(version: str) -> tuple[int, ...]:
break break
if digits: if digits:
parts.append(int(digits)) parts.append(int(digits))
return tuple(parts)
while len(parts) < 3:
parts.append(0)
return parts[0], parts[1], parts[2], post
def version_ge(installed: str, required: str) -> bool: def version_ge(installed: str, required: str) -> bool:
"""Compare PEP 440-like versions without external deps (post release suffix ignored).""" """Compare semver-like versions without external deps (supports legacy .postN)."""
return _parse_version(installed) >= _parse_version(required) return _parse_version(installed) >= _parse_version(required)
@@ -281,7 +299,7 @@ def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
f"record_video_enabled: {diagnostics.record_video_enabled}", f"record_video_enabled: {diagnostics.record_video_enabled}",
] ]
for issue in diagnostics.issues: for issue in diagnostics.issues:
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} {issue.message}") lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
return lines return lines

View File

@@ -1,4 +1,4 @@
"""Text-level checks that reusable-release-skill packages .env.example at package root.""" """Text-level checks for release-related GitHub Actions workflows."""
from __future__ import annotations from __future__ import annotations
@@ -8,14 +8,23 @@ import re
import pytest import pytest
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_WORKFLOW_PATH = os.path.join( _SKILL_WORKFLOW_PATH = os.path.join(
_REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml" _REPO_ROOT, ".github", "workflows", "reusable-release-skill.yaml"
) )
_PUBLISH_WORKFLOW_PATH = os.path.join(
_REPO_ROOT, ".github", "workflows", "publish.yml"
)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def workflow_text() -> str: def workflow_text() -> str:
with open(_WORKFLOW_PATH, encoding="utf-8") as f: with open(_SKILL_WORKFLOW_PATH, encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def publish_workflow_text() -> str:
with open(_PUBLISH_WORKFLOW_PATH, encoding="utf-8") as f:
return f.read() return f.read()
@@ -40,3 +49,18 @@ def test_workflow_does_not_package_dot_env(workflow_text: str) -> None:
r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)", r"os\.path\.join\(PACKAGE,\s*['\"]\.env['\"]\)",
workflow_text, workflow_text,
) )
def test_publish_workflow_triggers_on_main_only(publish_workflow_text: str) -> None:
assert "branches:" in publish_workflow_text
assert "- main" in publish_workflow_text
assert "tags:" not in publish_workflow_text
def test_publish_workflow_uses_pyproject_version_only(publish_workflow_text: str) -> None:
assert "ci_set_package_version" not in publish_workflow_text
assert "GITHUB_RUN_NUMBER" not in publish_workflow_text
assert "GITHUB_RUN_ID" not in publish_workflow_text
assert ".post" not in publish_workflow_text
assert "python3.12 -m build" in publish_workflow_text
assert "twine upload" in publish_workflow_text

View File

@@ -25,6 +25,11 @@ def test_version_ge_post_release() -> None:
assert version_ge("1.0.10.post23", "1.0.10") is True assert version_ge("1.0.10.post23", "1.0.10") is True
assert version_ge("1.0.10", "1.0.10") is True assert version_ge("1.0.10", "1.0.10") is True
assert version_ge("1.0.9.post19", "1.0.10") is False assert version_ge("1.0.9.post19", "1.0.10") is False
assert version_ge("1.0.10.post23", "1.0.10.post21") is True
assert version_ge("1.0.10.post21", "1.0.10.post23") is False
assert version_ge("1.0.10", "1.0.10.post1") is False
assert version_ge("1.0.11", "1.0.10.post99") is True
assert version_ge("1.0.11", "1.0.10") is True
def test_is_jiangchang_skill_core_from_skill_tree(tmp_path: Path) -> None: def test_is_jiangchang_skill_core_from_skill_tree(tmp_path: Path) -> None:
@@ -311,3 +316,32 @@ def test_runtime_diagnostics_frozen_dataclass() -> None:
) )
assert diag.has_fatal_issues is False assert diag.has_fatal_issues is False
assert diag.issue_codes() == ["ffmpeg_unavailable"] assert diag.issue_codes() == ["ffmpeg_unavailable"]
def test_format_runtime_health_lines_issue_separator_ascii() -> None:
diag = RuntimeDiagnostics(
skill_slug="x",
python_executable="python",
platform_kit_version="1.0.11",
platform_kit_min_version=None,
platform_kit_version_ok=None,
jiangchang_skill_core_file=None,
claw_data_root=None,
jiangchang_data_root=None,
resolved_data_root="/tmp",
media_assets_root="/tmp/media",
ffmpeg_available=False,
ffmpeg_path=None,
background_music_mp3_count=0,
background_music_usable_count=0,
background_music_issue=None,
background_music_sample_path=None,
record_video_enabled=False,
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
)
lines = format_runtime_health_lines(diag)
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
assert len(issue_lines) == 1
assert " - " in issue_lines[0]
assert "\u2014" not in issue_lines[0]
assert "\u2013" not in issue_lines[0]

View File

@@ -1,79 +0,0 @@
"""Patch pyproject.toml and __init__.py version for CI builds (not committed)."""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _read_base_version() -> str:
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if not match:
raise SystemExit("ERROR: version not found in pyproject.toml")
base = match.group(1)
return re.sub(r"\.post\d+$", "", base)
def resolve_version() -> str:
ref = os.environ.get("GITHUB_REF", "")
if ref.startswith("refs/tags/v"):
return ref.removeprefix("refs/tags/v")
if ref.startswith("refs/tags/"):
return ref.removeprefix("refs/tags/")
run_number = (
os.environ.get("GITHUB_RUN_NUMBER")
or os.environ.get("GITHUB_RUN_ID")
or "0"
)
base = _read_base_version()
return f"{base}.post{run_number}"
def patch_version(version: str) -> None:
pyproject = ROOT / "pyproject.toml"
py_text = pyproject.read_text(encoding="utf-8")
py_text, n = re.subn(
r'^version\s*=\s*"[^"]*"',
f'version = "{version}"',
py_text,
count=1,
flags=re.MULTILINE,
)
if n != 1:
raise SystemExit("ERROR: failed to patch version in pyproject.toml")
pyproject.write_text(py_text, encoding="utf-8")
init_py = ROOT / "src" / "jiangchang_desktop_sdk" / "__init__.py"
init_text = init_py.read_text(encoding="utf-8")
init_text, n = re.subn(
r'^__version__\s*=\s*"[^"]*"',
f'__version__ = "{version}"',
init_text,
count=1,
flags=re.MULTILINE,
)
if n != 1:
raise SystemExit("ERROR: failed to patch __version__ in __init__.py")
init_py.write_text(init_text, encoding="utf-8")
def main() -> None:
version = resolve_version()
print(f"Resolved package version: {version}")
patch_version(version)
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"package_version={version}\n")
if __name__ == "__main__":
try:
main()
except SystemExit as exc:
print(exc, file=sys.stderr)
raise