Skip to content

Commit ebe2ed0

Browse files
committed
build: add cross-platform binary release workflow
1 parent e625394 commit ebe2ed0

9 files changed

Lines changed: 478 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
inputs:
9+
version:
10+
description: "要发布的版本号,例如 1.3.0"
11+
required: true
12+
tag:
13+
description: "目标标签,例如 v1.3.0"
14+
required: true
15+
16+
permissions:
17+
contents: write
18+
19+
jobs:
20+
build:
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
include:
25+
- runner: windows-latest
26+
target: windows-x64
27+
- runner: macos-15-intel
28+
target: macos-x64
29+
- runner: macos-15
30+
target: macos-arm64
31+
- runner: ubuntu-24.04
32+
target: linux-x64
33+
- runner: ubuntu-24.04-arm
34+
target: linux-arm64
35+
runs-on: ${{ matrix.runner }}
36+
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@v4
40+
41+
- name: Setup Python
42+
uses: actions/setup-python@v5
43+
with:
44+
python-version: "3.11"
45+
46+
- name: Setup uv
47+
uses: astral-sh/setup-uv@v6
48+
49+
- name: Sync dependencies
50+
run: uv sync --group dev
51+
52+
- name: Build binary bundle
53+
run: uv run python scripts/build_binary_release.py --target "${{ matrix.target }}" --validate
54+
55+
- name: Upload release artifact
56+
uses: actions/upload-artifact@v4
57+
with:
58+
name: ${{ matrix.target }}
59+
path: release_artifacts/*
60+
if-no-files-found: error
61+
62+
publish:
63+
needs: build
64+
runs-on: ubuntu-24.04
65+
66+
steps:
67+
- name: Checkout
68+
uses: actions/checkout@v4
69+
with:
70+
fetch-depth: 0
71+
72+
- name: Download artifacts
73+
uses: actions/download-artifact@v4
74+
with:
75+
path: release_artifacts
76+
77+
- name: Setup Python
78+
uses: actions/setup-python@v5
79+
with:
80+
python-version: "3.11"
81+
82+
- name: Resolve version metadata
83+
id: release_meta
84+
shell: bash
85+
run: |
86+
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
87+
echo "tag=${{ github.event.inputs.tag }}" >> "${GITHUB_OUTPUT}"
88+
echo "version=${{ github.event.inputs.version }}" >> "${GITHUB_OUTPUT}"
89+
else
90+
tag="${GITHUB_REF_NAME}"
91+
version="${tag#v}"
92+
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
93+
echo "version=${version}" >> "${GITHUB_OUTPUT}"
94+
fi
95+
96+
- name: Extract release notes
97+
run: python scripts/extract_release_notes.py --version "${{ steps.release_meta.outputs.version }}" --output RELEASE_NOTES.md
98+
99+
- name: Publish GitHub release
100+
env:
101+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
102+
shell: bash
103+
run: |
104+
mapfile -t files < <(find release_artifacts -type f \( -name "*.zip" -o -name "*.tar.gz" \) | sort)
105+
if [ "${#files[@]}" -eq 0 ]; then
106+
echo "未找到发布产物。" >&2
107+
exit 1
108+
fi
109+
110+
if gh release view "${{ steps.release_meta.outputs.tag }}" >/dev/null 2>&1; then
111+
gh release upload "${{ steps.release_meta.outputs.tag }}" "${files[@]}" --clobber
112+
else
113+
gh release create "${{ steps.release_meta.outputs.tag }}" "${files[@]}" \
114+
--title "${{ steps.release_meta.outputs.tag }}" \
115+
--notes-file RELEASE_NOTES.md
116+
fi

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ venv.bak
4747
/data/logs/
4848
/data/kb/
4949
*.pkl
50+
/release_artifacts/
5051

5152
# 知识库文档
5253
/knowledge_base/

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ data/kb/ACTIVE_SNAPSHOT
117117
data/kb/<snapshot_id>/
118118
```
119119

120+
## 二进制发布
121+
122+
仓库内置了基于 PyInstaller 的 GitHub Release 打包链路,发布标签推送后会自动构建以下平台产物:
123+
124+
- `windows-x64`
125+
- `macos-x64`
126+
- `macos-arm64`
127+
- `linux-x64`
128+
- `linux-arm64`
129+
130+
本地手动打包示例:
131+
132+
```bash
133+
uv sync --group dev
134+
uv run python scripts/build_binary_release.py --target macos-x64 --validate
135+
```
136+
137+
发布工作流定义在 [`.github/workflows/release.yml`](/Volumes/Work/code/PyRAG-kit/.github/workflows/release.yml),发布说明会从 [CHANGELOG.md](/Volumes/Work/code/PyRAG-kit/CHANGELOG.md) 的对应版本节自动提取。
138+
120139
---
121140

122141
## 📖 开发者文档

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ dependencies = [
3636

3737
[dependency-groups]
3838
dev = [
39+
"pyinstaller>=6.16.0",
3940
"pytest>=9.0.2",
4041
]

scripts/build_binary_release.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import argparse
4+
import os
5+
import shutil
6+
import subprocess
7+
import sys
8+
import tarfile
9+
import tomllib
10+
import zipfile
11+
from pathlib import Path
12+
13+
14+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
15+
BUILD_ROOT = PROJECT_ROOT / "build"
16+
DIST_ROOT = PROJECT_ROOT / "dist"
17+
ARTIFACT_ROOT = PROJECT_ROOT / "release_artifacts"
18+
APP_NAME = "PyRAG-Kit"
19+
SPEC_PATH = PROJECT_ROOT / f"{APP_NAME}.spec"
20+
PACKAGE_FILES = [
21+
"README.md",
22+
"LICENSE",
23+
"DIFY_LICENSE",
24+
"config.toml.example",
25+
".env.example",
26+
]
27+
PACKAGE_DIRS = ["knowledge_base"]
28+
HIDDEN_IMPORTS = [
29+
"src.providers.google",
30+
"src.providers.openai",
31+
"src.providers.anthropic",
32+
"src.providers.qwen",
33+
"src.providers.volcengine",
34+
"src.providers.siliconflow",
35+
"src.providers.ollama",
36+
"src.providers.lm_studio",
37+
"src.providers.deepseek",
38+
"src.providers.grok",
39+
"src.providers.local_hash",
40+
"src.providers.jina",
41+
"src.providers.siliconflow_rerank",
42+
]
43+
44+
45+
def load_version() -> str:
46+
with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle:
47+
pyproject = tomllib.load(handle)
48+
return pyproject["project"]["version"]
49+
50+
51+
def parse_args() -> argparse.Namespace:
52+
parser = argparse.ArgumentParser(description="构建跨平台二进制发布包。")
53+
parser.add_argument(
54+
"--target",
55+
required=True,
56+
choices=[
57+
"windows-x64",
58+
"macos-x64",
59+
"macos-arm64",
60+
"linux-x64",
61+
"linux-arm64",
62+
],
63+
help="目标平台标识。",
64+
)
65+
parser.add_argument(
66+
"--validate",
67+
action="store_true",
68+
help="构建完成后执行最小烟测。",
69+
)
70+
return parser.parse_args()
71+
72+
73+
def clean_output_dirs() -> None:
74+
for directory in (BUILD_ROOT, DIST_ROOT, ARTIFACT_ROOT):
75+
if directory.exists():
76+
shutil.rmtree(directory)
77+
if SPEC_PATH.exists():
78+
SPEC_PATH.unlink()
79+
ARTIFACT_ROOT.mkdir(parents=True, exist_ok=True)
80+
81+
82+
def run_pyinstaller() -> None:
83+
command = [
84+
sys.executable,
85+
"-m",
86+
"PyInstaller",
87+
"--noconfirm",
88+
"--clean",
89+
"--onedir",
90+
"--name",
91+
APP_NAME,
92+
"--collect-data",
93+
"pyfiglet",
94+
]
95+
for hidden_import in HIDDEN_IMPORTS:
96+
command.extend(["--hidden-import", hidden_import])
97+
command.append("main.py")
98+
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
99+
100+
101+
def stage_bundle(target: str, version: str) -> Path:
102+
bundle_name = f"{APP_NAME}-{version}-{target}"
103+
bundle_root = ARTIFACT_ROOT / bundle_name
104+
app_source = DIST_ROOT / APP_NAME
105+
app_target = bundle_root / APP_NAME
106+
107+
shutil.copytree(app_source, app_target)
108+
for relative_file in PACKAGE_FILES:
109+
shutil.copy2(PROJECT_ROOT / relative_file, bundle_root / relative_file)
110+
for relative_dir in PACKAGE_DIRS:
111+
shutil.copytree(PROJECT_ROOT / relative_dir, bundle_root / relative_dir)
112+
113+
return bundle_root
114+
115+
116+
def archive_bundle(bundle_root: Path, target: str) -> Path:
117+
if target == "windows-x64":
118+
archive_path = bundle_root.parent / f"{bundle_root.name}.zip"
119+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
120+
for file_path in sorted(bundle_root.rglob("*")):
121+
archive.write(file_path, file_path.relative_to(bundle_root.parent))
122+
return archive_path
123+
124+
archive_path = bundle_root.parent / f"{bundle_root.name}.tar.gz"
125+
with tarfile.open(archive_path, "w:gz") as archive:
126+
archive.add(bundle_root, arcname=bundle_root.name)
127+
return archive_path
128+
129+
130+
def executable_path(bundle_root: Path) -> Path:
131+
executable_name = APP_NAME + (".exe" if os.name == "nt" else "")
132+
return bundle_root / APP_NAME / executable_name
133+
134+
135+
def validate_bundle(bundle_root: Path) -> None:
136+
executable = executable_path(bundle_root)
137+
subprocess.run(
138+
[str(executable)],
139+
cwd=bundle_root,
140+
input="4\n",
141+
text=True,
142+
capture_output=True,
143+
check=True,
144+
timeout=30,
145+
)
146+
147+
148+
def main() -> None:
149+
args = parse_args()
150+
version = load_version()
151+
152+
clean_output_dirs()
153+
run_pyinstaller()
154+
bundle_root = stage_bundle(args.target, version)
155+
archive_path = archive_bundle(bundle_root, args.target)
156+
if SPEC_PATH.exists():
157+
SPEC_PATH.unlink()
158+
159+
if args.validate:
160+
validate_bundle(bundle_root)
161+
162+
print(archive_path)
163+
164+
165+
if __name__ == "__main__":
166+
main()

scripts/extract_release_notes.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import argparse
4+
from pathlib import Path
5+
6+
7+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
8+
CHANGELOG_PATH = PROJECT_ROOT / "CHANGELOG.md"
9+
10+
11+
def parse_args() -> argparse.Namespace:
12+
parser = argparse.ArgumentParser(description="从 CHANGELOG.md 提取指定版本的发布说明。")
13+
parser.add_argument("--version", required=True, help="版本号,例如 1.3.0。")
14+
parser.add_argument("--output", required=True, help="输出文件路径。")
15+
return parser.parse_args()
16+
17+
18+
def extract_section(version: str) -> str:
19+
lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines()
20+
start_marker = f"## [{version}]"
21+
start_index = None
22+
end_index = len(lines)
23+
24+
for index, line in enumerate(lines):
25+
if line.startswith(start_marker):
26+
start_index = index
27+
continue
28+
if start_index is not None and line.startswith("## ["):
29+
end_index = index
30+
break
31+
32+
if start_index is None:
33+
raise ValueError(f"在 CHANGELOG.md 中未找到版本 {version}。")
34+
35+
return "\n".join(lines[start_index:end_index]).strip() + "\n"
36+
37+
38+
def main() -> None:
39+
args = parse_args()
40+
content = extract_section(args.version)
41+
Path(args.output).write_text(content, encoding="utf-8")
42+
43+
44+
if __name__ == "__main__":
45+
main()

src/utils/config.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
import functools
3+
import sys
34
import tomllib
45
from enum import Enum
56
from pathlib import Path
@@ -14,7 +15,19 @@
1415
# =================================================================
1516

1617
# 项目根目录
17-
ROOT_DIR = Path(__file__).parent.parent.parent
18+
def resolve_app_root() -> Path:
19+
"""
20+
返回应用根目录。
21+
22+
- 源码运行时使用仓库根目录。
23+
- PyInstaller 冻结运行时使用可执行文件所在目录。
24+
"""
25+
if getattr(sys, "frozen", False):
26+
return Path(sys.executable).resolve().parent
27+
return Path(__file__).resolve().parent.parent.parent
28+
29+
30+
ROOT_DIR = resolve_app_root()
1831
# 配置文件路径
1932
CONFIG_TOML_PATH = ROOT_DIR / 'config.toml'
2033

0 commit comments

Comments
 (0)