tg-bot-plugin-buildkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tg_bot_plugin_buildkit-0.1.0/.github/workflows/ci.yml +36 -0
- tg_bot_plugin_buildkit-0.1.0/.github/workflows/release-plugin.yml +170 -0
- tg_bot_plugin_buildkit-0.1.0/.github/workflows/release.yml +82 -0
- tg_bot_plugin_buildkit-0.1.0/.gitignore +5 -0
- tg_bot_plugin_buildkit-0.1.0/Dockerfile +14 -0
- tg_bot_plugin_buildkit-0.1.0/PKG-INFO +68 -0
- tg_bot_plugin_buildkit-0.1.0/README.md +56 -0
- tg_bot_plugin_buildkit-0.1.0/pyproject.toml +31 -0
- tg_bot_plugin_buildkit-0.1.0/scripts/check_release_tag.py +53 -0
- tg_bot_plugin_buildkit-0.1.0/src/tg_bot_plugin_buildkit/__init__.py +19 -0
- tg_bot_plugin_buildkit-0.1.0/src/tg_bot_plugin_buildkit/buildkit.py +307 -0
- tg_bot_plugin_buildkit-0.1.0/src/tg_bot_plugin_buildkit/cli.py +104 -0
- tg_bot_plugin_buildkit-0.1.0/src/tg_bot_plugin_buildkit/core_adapter.py +60 -0
- tg_bot_plugin_buildkit-0.1.0/tests/test_buildkit.py +145 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout buildkit
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.13"
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: |
|
|
23
|
+
python -m pip install --upgrade pip
|
|
24
|
+
python -m pip install -e ".[dev]" build
|
|
25
|
+
|
|
26
|
+
- name: Run unit tests
|
|
27
|
+
run: |
|
|
28
|
+
python -m pytest -q
|
|
29
|
+
|
|
30
|
+
- name: Build package
|
|
31
|
+
run: |
|
|
32
|
+
python -m build
|
|
33
|
+
|
|
34
|
+
- name: Docker build smoke test
|
|
35
|
+
run: |
|
|
36
|
+
docker build -t tg-bot-plugin-buildkit:ci .
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
name: release-plugin
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
allow_unsigned_dev:
|
|
7
|
+
required: false
|
|
8
|
+
type: boolean
|
|
9
|
+
default: false
|
|
10
|
+
create_github_release:
|
|
11
|
+
required: false
|
|
12
|
+
type: boolean
|
|
13
|
+
default: true
|
|
14
|
+
python_version:
|
|
15
|
+
required: false
|
|
16
|
+
type: string
|
|
17
|
+
default: "3.13"
|
|
18
|
+
runtime_profile_id:
|
|
19
|
+
required: true
|
|
20
|
+
type: string
|
|
21
|
+
source_dir:
|
|
22
|
+
required: false
|
|
23
|
+
type: string
|
|
24
|
+
default: "."
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
build:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
permissions:
|
|
30
|
+
contents: write
|
|
31
|
+
id-token: write
|
|
32
|
+
steps:
|
|
33
|
+
- name: Checkout
|
|
34
|
+
uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- name: Set up Python
|
|
37
|
+
uses: actions/setup-python@v5
|
|
38
|
+
with:
|
|
39
|
+
python-version: "${{ inputs.python_version }}"
|
|
40
|
+
|
|
41
|
+
- name: Install buildkit
|
|
42
|
+
run: |
|
|
43
|
+
python -m pip install --upgrade pip
|
|
44
|
+
python -m pip install tg-bot-plugin-buildkit tg-bot-plugin-contract-core
|
|
45
|
+
|
|
46
|
+
- name: Validate manifest version against tag
|
|
47
|
+
if: github.ref_type == 'tag'
|
|
48
|
+
env:
|
|
49
|
+
SOURCE_DIR: ${{ inputs.source_dir }}
|
|
50
|
+
run: |
|
|
51
|
+
python - <<'PY'
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
source_dir = Path(os.environ["SOURCE_DIR"]).expanduser().resolve()
|
|
57
|
+
manifest_path = source_dir / "manifest.json"
|
|
58
|
+
if not manifest_path.is_file():
|
|
59
|
+
raise SystemExit(f"missing manifest.json: {manifest_path}")
|
|
60
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
61
|
+
plugin_version = str(manifest.get("plugin_version", "")).strip()
|
|
62
|
+
if plugin_version == "":
|
|
63
|
+
raise SystemExit("manifest.plugin_version must not be empty")
|
|
64
|
+
tag_name = str(os.environ.get("GITHUB_REF_NAME", "")).strip()
|
|
65
|
+
expected_tag = f"v{plugin_version}"
|
|
66
|
+
if tag_name != expected_tag:
|
|
67
|
+
raise SystemExit(
|
|
68
|
+
f"release tag mismatch: tag={tag_name}, manifest.plugin_version={plugin_version}",
|
|
69
|
+
)
|
|
70
|
+
print(f"release tag matches manifest.plugin_version: {plugin_version}")
|
|
71
|
+
PY
|
|
72
|
+
|
|
73
|
+
- name: Build bundle
|
|
74
|
+
id: build_bundle
|
|
75
|
+
run: |
|
|
76
|
+
build_json="$(mktemp)"
|
|
77
|
+
tg-bot-plugin-buildkit build \
|
|
78
|
+
--source-dir "${{ inputs.source_dir }}" \
|
|
79
|
+
--runtime-profile-id "${{ inputs.runtime_profile_id }}" \
|
|
80
|
+
> "${build_json}"
|
|
81
|
+
cat "${build_json}"
|
|
82
|
+
python - "${build_json}" <<'PY' >> "$GITHUB_OUTPUT"
|
|
83
|
+
import json
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
import sys
|
|
86
|
+
|
|
87
|
+
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
88
|
+
bundle_path = Path(payload["bundle_path"]).resolve()
|
|
89
|
+
print(f"bundle_path={bundle_path}")
|
|
90
|
+
print(f"bundle_name={bundle_path.name}")
|
|
91
|
+
print(f"package_sha256={payload['package_sha256']}")
|
|
92
|
+
print(f"plugin_version={payload['plugin_version']}")
|
|
93
|
+
PY
|
|
94
|
+
|
|
95
|
+
- name: Inspect bundle
|
|
96
|
+
run: |
|
|
97
|
+
tg-bot-plugin-buildkit inspect --bundle "${{ steps.build_bundle.outputs.bundle_path }}"
|
|
98
|
+
|
|
99
|
+
- name: Verify bundle
|
|
100
|
+
run: |
|
|
101
|
+
args=()
|
|
102
|
+
if [ "${{ inputs.allow_unsigned_dev }}" = "true" ]; then
|
|
103
|
+
args+=(--allow-unsigned-dev)
|
|
104
|
+
fi
|
|
105
|
+
tg-bot-plugin-buildkit verify \
|
|
106
|
+
--bundle "${{ steps.build_bundle.outputs.bundle_path }}" \
|
|
107
|
+
"${args[@]}"
|
|
108
|
+
|
|
109
|
+
- name: Validate same-profile bundle
|
|
110
|
+
run: |
|
|
111
|
+
args=()
|
|
112
|
+
if [ "${{ inputs.allow_unsigned_dev }}" = "true" ]; then
|
|
113
|
+
args+=(--allow-unsigned-dev)
|
|
114
|
+
fi
|
|
115
|
+
tg-bot-plugin-buildkit validate \
|
|
116
|
+
--mode same-profile \
|
|
117
|
+
--bundle "${{ steps.build_bundle.outputs.bundle_path }}" \
|
|
118
|
+
"${args[@]}"
|
|
119
|
+
|
|
120
|
+
- name: Validate target-profile bundle
|
|
121
|
+
run: |
|
|
122
|
+
args=()
|
|
123
|
+
if [ "${{ inputs.allow_unsigned_dev }}" = "true" ]; then
|
|
124
|
+
args+=(--allow-unsigned-dev)
|
|
125
|
+
fi
|
|
126
|
+
tg-bot-plugin-buildkit validate \
|
|
127
|
+
--mode target-profile \
|
|
128
|
+
--bundle "${{ steps.build_bundle.outputs.bundle_path }}" \
|
|
129
|
+
--target-runtime-profile-id "${{ inputs.runtime_profile_id }}" \
|
|
130
|
+
"${args[@]}"
|
|
131
|
+
|
|
132
|
+
- name: Reproducibility smoke check
|
|
133
|
+
run: |
|
|
134
|
+
repro_root="$(mktemp -d)"
|
|
135
|
+
tg-bot-plugin-buildkit build \
|
|
136
|
+
--source-dir "${{ inputs.source_dir }}" \
|
|
137
|
+
--output-dir "${repro_root}/first" \
|
|
138
|
+
--runtime-profile-id "${{ inputs.runtime_profile_id }}" \
|
|
139
|
+
> "${repro_root}/first.json"
|
|
140
|
+
tg-bot-plugin-buildkit build \
|
|
141
|
+
--source-dir "${{ inputs.source_dir }}" \
|
|
142
|
+
--output-dir "${repro_root}/second" \
|
|
143
|
+
--runtime-profile-id "${{ inputs.runtime_profile_id }}" \
|
|
144
|
+
> "${repro_root}/second.json"
|
|
145
|
+
python - "${repro_root}/first.json" "${repro_root}/second.json" <<'PY'
|
|
146
|
+
import json
|
|
147
|
+
from pathlib import Path
|
|
148
|
+
import sys
|
|
149
|
+
|
|
150
|
+
first = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
151
|
+
second = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
|
152
|
+
if first["package_sha256"] != second["package_sha256"]:
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
"reproducibility smoke check failed: package_sha256 mismatch "
|
|
155
|
+
f"{first['package_sha256']} != {second['package_sha256']}",
|
|
156
|
+
)
|
|
157
|
+
print(f"reproducibility smoke check passed: {first['package_sha256']}")
|
|
158
|
+
PY
|
|
159
|
+
|
|
160
|
+
- name: Upload bundle artifact
|
|
161
|
+
uses: actions/upload-artifact@v4
|
|
162
|
+
with:
|
|
163
|
+
name: plugin-bundle-${{ inputs.runtime_profile_id }}
|
|
164
|
+
path: ${{ steps.build_bundle.outputs.bundle_path }}
|
|
165
|
+
|
|
166
|
+
- name: Publish GitHub Release asset
|
|
167
|
+
if: github.ref_type == 'tag' && inputs.create_github_release
|
|
168
|
+
uses: softprops/action-gh-release@v2
|
|
169
|
+
with:
|
|
170
|
+
files: ${{ steps.build_bundle.outputs.bundle_path }}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
packages: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
environment: pypi
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout buildkit
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
python -m pip install -e ".[dev]" build
|
|
30
|
+
|
|
31
|
+
- name: Validate tag and version
|
|
32
|
+
run: |
|
|
33
|
+
python scripts/check_release_tag.py --tag "${{ github.ref_name }}"
|
|
34
|
+
|
|
35
|
+
- name: Run unit tests
|
|
36
|
+
run: |
|
|
37
|
+
python -m pytest -q
|
|
38
|
+
|
|
39
|
+
- name: Build package
|
|
40
|
+
run: |
|
|
41
|
+
python -m build
|
|
42
|
+
|
|
43
|
+
- name: Upload build artifact
|
|
44
|
+
uses: actions/upload-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: tg-bot-plugin-buildkit-dist
|
|
47
|
+
path: dist/*
|
|
48
|
+
|
|
49
|
+
- name: Publish GitHub Release
|
|
50
|
+
uses: softprops/action-gh-release@v2
|
|
51
|
+
with:
|
|
52
|
+
files: dist/*
|
|
53
|
+
|
|
54
|
+
- name: Set up Docker Buildx
|
|
55
|
+
uses: docker/setup-buildx-action@v3
|
|
56
|
+
|
|
57
|
+
- name: Log in to GHCR
|
|
58
|
+
uses: docker/login-action@v3
|
|
59
|
+
with:
|
|
60
|
+
registry: ghcr.io
|
|
61
|
+
username: ${{ github.actor }}
|
|
62
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
63
|
+
|
|
64
|
+
- name: Extract Docker metadata
|
|
65
|
+
id: docker_meta
|
|
66
|
+
uses: docker/metadata-action@v5
|
|
67
|
+
with:
|
|
68
|
+
images: ghcr.io/${{ github.repository_owner }}/tg-bot-plugin-buildkit
|
|
69
|
+
tags: |
|
|
70
|
+
type=ref,event=tag
|
|
71
|
+
type=raw,value=latest
|
|
72
|
+
|
|
73
|
+
- name: Publish builder image
|
|
74
|
+
uses: docker/build-push-action@v6
|
|
75
|
+
with:
|
|
76
|
+
context: .
|
|
77
|
+
push: true
|
|
78
|
+
tags: ${{ steps.docker_meta.outputs.tags }}
|
|
79
|
+
labels: ${{ steps.docker_meta.outputs.labels }}
|
|
80
|
+
|
|
81
|
+
- name: Publish to PyPI
|
|
82
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM python:3.13-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /workspace
|
|
4
|
+
|
|
5
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
6
|
+
ENV PYTHONUNBUFFERED=1
|
|
7
|
+
|
|
8
|
+
COPY pyproject.toml README.md ./
|
|
9
|
+
COPY src ./src
|
|
10
|
+
|
|
11
|
+
RUN python -m pip install --upgrade pip && \
|
|
12
|
+
python -m pip install .
|
|
13
|
+
|
|
14
|
+
ENTRYPOINT ["tg-bot-plugin-buildkit"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-bot-plugin-buildkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 基于共享合同核心构建并校验 TG-BOT 插件 bundle。
|
|
5
|
+
Author: Fire Dragons
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: tg-bot-plugin-contract-core==0.1.1
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# tg-bot-plugin-buildkit
|
|
14
|
+
|
|
15
|
+
`tg-bot-plugin-buildkit` 是面向 TG-BOT 单插件仓库的可复用构建与校验 CLI。
|
|
16
|
+
|
|
17
|
+
它封装共享 `tg-bot-plugin-contract-core` 包,并提供:
|
|
18
|
+
|
|
19
|
+
- `build`
|
|
20
|
+
- `inspect`
|
|
21
|
+
- `verify`
|
|
22
|
+
- `validate --mode same-profile`
|
|
23
|
+
- `validate --mode target-profile`
|
|
24
|
+
- `benchmark`
|
|
25
|
+
- `install-local --dev-unsigned`
|
|
26
|
+
|
|
27
|
+
## 本地开发
|
|
28
|
+
|
|
29
|
+
该包的正式发布形态固定依赖已发布的 `tg-bot-plugin-contract-core==0.1.1`。
|
|
30
|
+
|
|
31
|
+
如果需要在未发布改动上进行本地多仓联调,CLI 仍可通过以下环境变量解析兄弟仓源码:
|
|
32
|
+
|
|
33
|
+
- `TG_BOT_PLUGIN_CONTRACT_CORE_SRC=/path/to/tg-bot-plugin-contract-core/src`
|
|
34
|
+
|
|
35
|
+
这条桥接路径只作为开发态回退,不再是正式 CI / release 的 canonical 依赖方式。
|
|
36
|
+
|
|
37
|
+
## CI / 发布
|
|
38
|
+
|
|
39
|
+
- 仓库 CI 会执行:
|
|
40
|
+
- `pytest`
|
|
41
|
+
- wheel / sdist 构建
|
|
42
|
+
- Docker builder 镜像构建 smoke test
|
|
43
|
+
- `tag push v*` 会额外执行:
|
|
44
|
+
- `tag == project.version` 校验
|
|
45
|
+
- PyPI 发布
|
|
46
|
+
- GHCR builder 镜像发布
|
|
47
|
+
- GitHub Release 附件上传
|
|
48
|
+
- 可复用 workflow `release-plugin.yml` 用于单插件仓库正式发版,固定执行:
|
|
49
|
+
- `tag == manifest.plugin_version` 校验
|
|
50
|
+
- `build`
|
|
51
|
+
- `inspect`
|
|
52
|
+
- `verify`
|
|
53
|
+
- `validate --mode same-profile`
|
|
54
|
+
- `validate --mode target-profile`
|
|
55
|
+
- 可复现性 smoke check
|
|
56
|
+
- bundle 产物上传 / Release asset 发布
|
|
57
|
+
|
|
58
|
+
## 使用方式
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python -m tg_bot_plugin_buildkit.cli build --source-dir ./plugin
|
|
62
|
+
python -m tg_bot_plugin_buildkit.cli inspect --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
63
|
+
python -m tg_bot_plugin_buildkit.cli verify --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
64
|
+
python -m tg_bot_plugin_buildkit.cli validate --mode same-profile --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
65
|
+
python -m tg_bot_plugin_buildkit.cli install-local --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg --plugin-root ./plugins --dev-unsigned
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`build` 默认会排除 Git/CI/测试/虚拟环境与 `dist` 等仓库控制或生成目录,避免把仓库元数据和旧 bundle 再次打进新的 `.tgpkg`。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# tg-bot-plugin-buildkit
|
|
2
|
+
|
|
3
|
+
`tg-bot-plugin-buildkit` 是面向 TG-BOT 单插件仓库的可复用构建与校验 CLI。
|
|
4
|
+
|
|
5
|
+
它封装共享 `tg-bot-plugin-contract-core` 包,并提供:
|
|
6
|
+
|
|
7
|
+
- `build`
|
|
8
|
+
- `inspect`
|
|
9
|
+
- `verify`
|
|
10
|
+
- `validate --mode same-profile`
|
|
11
|
+
- `validate --mode target-profile`
|
|
12
|
+
- `benchmark`
|
|
13
|
+
- `install-local --dev-unsigned`
|
|
14
|
+
|
|
15
|
+
## 本地开发
|
|
16
|
+
|
|
17
|
+
该包的正式发布形态固定依赖已发布的 `tg-bot-plugin-contract-core==0.1.1`。
|
|
18
|
+
|
|
19
|
+
如果需要在未发布改动上进行本地多仓联调,CLI 仍可通过以下环境变量解析兄弟仓源码:
|
|
20
|
+
|
|
21
|
+
- `TG_BOT_PLUGIN_CONTRACT_CORE_SRC=/path/to/tg-bot-plugin-contract-core/src`
|
|
22
|
+
|
|
23
|
+
这条桥接路径只作为开发态回退,不再是正式 CI / release 的 canonical 依赖方式。
|
|
24
|
+
|
|
25
|
+
## CI / 发布
|
|
26
|
+
|
|
27
|
+
- 仓库 CI 会执行:
|
|
28
|
+
- `pytest`
|
|
29
|
+
- wheel / sdist 构建
|
|
30
|
+
- Docker builder 镜像构建 smoke test
|
|
31
|
+
- `tag push v*` 会额外执行:
|
|
32
|
+
- `tag == project.version` 校验
|
|
33
|
+
- PyPI 发布
|
|
34
|
+
- GHCR builder 镜像发布
|
|
35
|
+
- GitHub Release 附件上传
|
|
36
|
+
- 可复用 workflow `release-plugin.yml` 用于单插件仓库正式发版,固定执行:
|
|
37
|
+
- `tag == manifest.plugin_version` 校验
|
|
38
|
+
- `build`
|
|
39
|
+
- `inspect`
|
|
40
|
+
- `verify`
|
|
41
|
+
- `validate --mode same-profile`
|
|
42
|
+
- `validate --mode target-profile`
|
|
43
|
+
- 可复现性 smoke check
|
|
44
|
+
- bundle 产物上传 / Release asset 发布
|
|
45
|
+
|
|
46
|
+
## 使用方式
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python -m tg_bot_plugin_buildkit.cli build --source-dir ./plugin
|
|
50
|
+
python -m tg_bot_plugin_buildkit.cli inspect --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
51
|
+
python -m tg_bot_plugin_buildkit.cli verify --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
52
|
+
python -m tg_bot_plugin_buildkit.cli validate --mode same-profile --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg
|
|
53
|
+
python -m tg_bot_plugin_buildkit.cli install-local --bundle ./dist/plugins/demo/demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg --plugin-root ./plugins --dev-unsigned
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`build` 默认会排除 Git/CI/测试/虚拟环境与 `dist` 等仓库控制或生成目录,避免把仓库元数据和旧 bundle 再次打进新的 `.tgpkg`。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tg-bot-plugin-buildkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "基于共享合同核心构建并校验 TG-BOT 插件 bundle。"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Fire Dragons" },
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"tg-bot-plugin-contract-core==0.1.1",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.3.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
tg-bot-plugin-buildkit = "tg_bot_plugin_buildkit.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/tg_bot_plugin_buildkit"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""校验 Git tag 与 pyproject 中的项目版本一致。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_args() -> argparse.Namespace:
|
|
13
|
+
parser = argparse.ArgumentParser(description="校验 release tag 与项目版本是否一致。")
|
|
14
|
+
parser.add_argument("--tag", required=True, help="例如 v0.1.0")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--project-root",
|
|
17
|
+
default=".",
|
|
18
|
+
help="包含 pyproject.toml 的项目根目录。",
|
|
19
|
+
)
|
|
20
|
+
return parser.parse_args()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
args = _parse_args()
|
|
25
|
+
project_root = Path(args.project_root).expanduser().resolve()
|
|
26
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
27
|
+
tag = str(args.tag or "").strip()
|
|
28
|
+
if not pyproject_path.is_file():
|
|
29
|
+
print(f"missing pyproject.toml: {pyproject_path}", file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
if not tag.startswith("v"):
|
|
32
|
+
print(f"release tag must start with 'v': {tag}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
payload = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
36
|
+
project_version = str(payload.get("project", {}).get("version", "")).strip()
|
|
37
|
+
tag_version = tag[1:]
|
|
38
|
+
if project_version == "":
|
|
39
|
+
print("project.version is missing in pyproject.toml", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
if project_version != tag_version:
|
|
42
|
+
print(
|
|
43
|
+
f"release tag mismatch: tag={tag_version}, project.version={project_version}",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
print(f"release tag matches project version: {project_version}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""TG-BOT 插件 bundle 的可复用构建工具集。"""
|
|
2
|
+
|
|
3
|
+
from .buildkit import (
|
|
4
|
+
benchmark_bundle,
|
|
5
|
+
build_bundle,
|
|
6
|
+
inspect_bundle,
|
|
7
|
+
install_local_bundle,
|
|
8
|
+
validate_bundle,
|
|
9
|
+
verify_bundle,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"benchmark_bundle",
|
|
14
|
+
"build_bundle",
|
|
15
|
+
"inspect_bundle",
|
|
16
|
+
"install_local_bundle",
|
|
17
|
+
"validate_bundle",
|
|
18
|
+
"verify_bundle",
|
|
19
|
+
]
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""构建在共享合同核心之上的高层 buildkit 操作。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .core_adapter import (
|
|
14
|
+
compute_artifact_digest,
|
|
15
|
+
compute_package_sha256,
|
|
16
|
+
inspect_bundle as shared_inspect_bundle,
|
|
17
|
+
normalize_manifest_payload,
|
|
18
|
+
validate_bundle_same_profile,
|
|
19
|
+
validate_bundle_target_profile,
|
|
20
|
+
verify_bundle as shared_verify_bundle,
|
|
21
|
+
write_reproducible_bundle,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MANIFEST_FILE_NAME = "manifest.json"
|
|
25
|
+
SUPPORTED_BUNDLE_SUFFIX = ".tgpkg"
|
|
26
|
+
_BUNDLE_EXCLUDED_DIRECTORY_NAMES = {
|
|
27
|
+
".git",
|
|
28
|
+
".hg",
|
|
29
|
+
".idea",
|
|
30
|
+
".mypy_cache",
|
|
31
|
+
".nox",
|
|
32
|
+
".pytest_cache",
|
|
33
|
+
".ruff_cache",
|
|
34
|
+
".svn",
|
|
35
|
+
".tox",
|
|
36
|
+
".venv",
|
|
37
|
+
".vscode",
|
|
38
|
+
"__pycache__",
|
|
39
|
+
"node_modules",
|
|
40
|
+
}
|
|
41
|
+
_BUNDLE_EXCLUDED_TOP_LEVEL_NAMES = {
|
|
42
|
+
"build",
|
|
43
|
+
"dist",
|
|
44
|
+
"docs",
|
|
45
|
+
"scripts",
|
|
46
|
+
"tests",
|
|
47
|
+
}
|
|
48
|
+
_BUNDLE_EXCLUDED_TOP_LEVEL_FILE_NAMES = {
|
|
49
|
+
".gitignore",
|
|
50
|
+
".python-version",
|
|
51
|
+
"Makefile",
|
|
52
|
+
"README",
|
|
53
|
+
"README.md",
|
|
54
|
+
"poetry.lock",
|
|
55
|
+
"pyproject.toml",
|
|
56
|
+
"uv.lock",
|
|
57
|
+
}
|
|
58
|
+
_BUNDLE_EXCLUDED_FILE_SUFFIXES = {
|
|
59
|
+
".pyc",
|
|
60
|
+
SUPPORTED_BUNDLE_SUFFIX,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stable_json(payload: Any) -> str:
|
|
65
|
+
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_runtime_profile_id() -> str:
|
|
69
|
+
implementation = platform.python_implementation().lower()
|
|
70
|
+
version = f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}"
|
|
71
|
+
system_name = platform.system().lower()
|
|
72
|
+
machine_name = platform.machine().lower().replace("/", "_")
|
|
73
|
+
libc_name, _libc_version = platform.libc_ver()
|
|
74
|
+
abi = libc_name.lower() if libc_name else ("darwin" if system_name == "darwin" else "unknown")
|
|
75
|
+
return "-".join((implementation, version, system_name, machine_name, abi))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_manifest_payload(source_dir: Path) -> dict[str, Any]:
|
|
79
|
+
manifest_path = source_dir / MANIFEST_FILE_NAME
|
|
80
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
81
|
+
if not isinstance(payload, dict):
|
|
82
|
+
raise ValueError("manifest root must be an object")
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _write_manifest_payload(source_dir: Path, payload: dict[str, Any]) -> None:
|
|
87
|
+
(source_dir / MANIFEST_FILE_NAME).write_text(_stable_json(payload), encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _output_bundle_path(output_dir: Path, *, plugin_id: str, plugin_version: str, runtime_profile_id: str) -> Path:
|
|
91
|
+
return output_dir / plugin_id / f"{plugin_id}-{plugin_version}-{runtime_profile_id}{SUPPORTED_BUNDLE_SUFFIX}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _output_roots_to_ignore(source_dir: Path, output_dir: Path) -> set[str]:
|
|
95
|
+
try:
|
|
96
|
+
relpath = output_dir.relative_to(source_dir)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return set()
|
|
99
|
+
if not relpath.parts:
|
|
100
|
+
return set()
|
|
101
|
+
return {relpath.parts[0]}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _should_exclude_bundle_source(path: Path, source_dir: Path, extra_top_level_names: set[str]) -> bool:
|
|
105
|
+
relpath = path.relative_to(source_dir)
|
|
106
|
+
if not relpath.parts:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
first_part = relpath.parts[0]
|
|
110
|
+
if first_part in extra_top_level_names:
|
|
111
|
+
return True
|
|
112
|
+
if first_part.startswith(".") and first_part != "META-INF":
|
|
113
|
+
return True
|
|
114
|
+
if any(part in _BUNDLE_EXCLUDED_DIRECTORY_NAMES for part in relpath.parts):
|
|
115
|
+
return True
|
|
116
|
+
if first_part in _BUNDLE_EXCLUDED_TOP_LEVEL_NAMES:
|
|
117
|
+
return True
|
|
118
|
+
if len(relpath.parts) == 1 and path.name in _BUNDLE_EXCLUDED_TOP_LEVEL_FILE_NAMES:
|
|
119
|
+
return True
|
|
120
|
+
if path.is_file() and path.suffix in _BUNDLE_EXCLUDED_FILE_SUFFIXES:
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_copy_ignore(source_dir: Path, output_dir: Path):
|
|
126
|
+
extra_top_level_names = _output_roots_to_ignore(source_dir, output_dir)
|
|
127
|
+
|
|
128
|
+
def _ignore(current_dir: str, names: list[str]) -> list[str]:
|
|
129
|
+
current_path = Path(current_dir)
|
|
130
|
+
ignored: list[str] = []
|
|
131
|
+
for name in names:
|
|
132
|
+
candidate = current_path / name
|
|
133
|
+
if _should_exclude_bundle_source(candidate, source_dir, extra_top_level_names):
|
|
134
|
+
ignored.append(name)
|
|
135
|
+
return ignored
|
|
136
|
+
|
|
137
|
+
return _ignore
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_bundle(
|
|
141
|
+
*,
|
|
142
|
+
source_dir: str | Path,
|
|
143
|
+
output_dir: str | Path | None = None,
|
|
144
|
+
runtime_profile_id: str = "",
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
resolved_source_dir = Path(source_dir).expanduser().resolve()
|
|
147
|
+
resolved_output_dir = (
|
|
148
|
+
Path(output_dir).expanduser().resolve()
|
|
149
|
+
if output_dir
|
|
150
|
+
else (resolved_source_dir / "dist" / "plugins").resolve()
|
|
151
|
+
)
|
|
152
|
+
manifest_payload = _read_manifest_payload(resolved_source_dir)
|
|
153
|
+
effective_runtime_profile_id = str(runtime_profile_id or "").strip() or build_runtime_profile_id()
|
|
154
|
+
manifest_payload["runtime_profile_id"] = effective_runtime_profile_id
|
|
155
|
+
manifest_payload["artifact_digest"] = ""
|
|
156
|
+
manifest = normalize_manifest_payload(manifest_payload, require_artifact_digest=False)
|
|
157
|
+
|
|
158
|
+
with tempfile.TemporaryDirectory(prefix=f"tg-bot-buildkit-{manifest.plugin_id}-") as temp_dir_str:
|
|
159
|
+
staging_dir = Path(temp_dir_str) / manifest.plugin_id
|
|
160
|
+
shutil.copytree(
|
|
161
|
+
resolved_source_dir,
|
|
162
|
+
staging_dir,
|
|
163
|
+
ignore=_build_copy_ignore(resolved_source_dir, resolved_output_dir),
|
|
164
|
+
)
|
|
165
|
+
_write_manifest_payload(staging_dir, manifest.raw)
|
|
166
|
+
artifact_digest = compute_artifact_digest(staging_dir, manifest.raw)
|
|
167
|
+
manifest_payload = dict(manifest.raw)
|
|
168
|
+
manifest_payload["artifact_digest"] = artifact_digest
|
|
169
|
+
_write_manifest_payload(staging_dir, manifest_payload)
|
|
170
|
+
bundle_path = _output_bundle_path(
|
|
171
|
+
resolved_output_dir,
|
|
172
|
+
plugin_id=manifest.plugin_id,
|
|
173
|
+
plugin_version=manifest.plugin_version,
|
|
174
|
+
runtime_profile_id=effective_runtime_profile_id,
|
|
175
|
+
)
|
|
176
|
+
bundle_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
write_reproducible_bundle(staging_dir, bundle_path)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"build_status": "ok",
|
|
181
|
+
"plugin_id": manifest.plugin_id,
|
|
182
|
+
"plugin_version": manifest.plugin_version,
|
|
183
|
+
"runtime_profile_id": effective_runtime_profile_id,
|
|
184
|
+
"artifact_digest": artifact_digest,
|
|
185
|
+
"package_sha256": compute_package_sha256(bundle_path),
|
|
186
|
+
"bundle_path": str(bundle_path),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def inspect_bundle(bundle_path: str | Path) -> dict[str, Any]:
|
|
191
|
+
inspection = shared_inspect_bundle(bundle_path)
|
|
192
|
+
return {
|
|
193
|
+
"inspect_status": "ok",
|
|
194
|
+
"plugin_id": inspection.plugin_id,
|
|
195
|
+
"plugin_version": inspection.plugin_version,
|
|
196
|
+
"runtime_profile_id": inspection.runtime_profile_id,
|
|
197
|
+
"artifact_digest": inspection.artifact_digest,
|
|
198
|
+
"package_sha256": inspection.package_sha256,
|
|
199
|
+
"entrypoint": inspection.manifest.entrypoint,
|
|
200
|
+
"bundle_path": str(inspection.bundle_path),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def verify_bundle(bundle_path: str | Path, *, allow_unsigned_dev: bool = False) -> dict[str, Any]:
|
|
205
|
+
verification = shared_verify_bundle(bundle_path, allow_unsigned_dev=allow_unsigned_dev)
|
|
206
|
+
signer_identity = ""
|
|
207
|
+
if verification.signer_identity is not None:
|
|
208
|
+
signer_identity = verification.signer_identity.persistent_value()
|
|
209
|
+
return {
|
|
210
|
+
"verify_status": "ok",
|
|
211
|
+
"plugin_id": verification.plugin_id,
|
|
212
|
+
"plugin_version": verification.plugin_version,
|
|
213
|
+
"runtime_profile_id": verification.runtime_profile_id,
|
|
214
|
+
"artifact_digest": verification.artifact_digest,
|
|
215
|
+
"package_sha256": verification.package_sha256,
|
|
216
|
+
"signature_verification_status": verification.signature_verification_status,
|
|
217
|
+
"signer_identity": signer_identity,
|
|
218
|
+
"bundle_path": str(Path(bundle_path).expanduser().resolve()),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate_bundle(
|
|
223
|
+
bundle_path: str | Path,
|
|
224
|
+
*,
|
|
225
|
+
mode: str,
|
|
226
|
+
target_runtime_profile_id: str = "",
|
|
227
|
+
allow_unsigned_dev: bool = False,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
resolved_bundle_path = Path(bundle_path).expanduser().resolve()
|
|
230
|
+
if mode == "same-profile":
|
|
231
|
+
verification = validate_bundle_same_profile(
|
|
232
|
+
resolved_bundle_path,
|
|
233
|
+
runtime_profile_id=build_runtime_profile_id(),
|
|
234
|
+
allow_unsigned_dev=allow_unsigned_dev,
|
|
235
|
+
)
|
|
236
|
+
elif mode == "target-profile":
|
|
237
|
+
verification = validate_bundle_target_profile(
|
|
238
|
+
resolved_bundle_path,
|
|
239
|
+
target_runtime_profile_id=str(target_runtime_profile_id or "").strip(),
|
|
240
|
+
allow_unsigned_dev=allow_unsigned_dev,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(f"unsupported validate mode: {mode}")
|
|
244
|
+
return {
|
|
245
|
+
"validate_status": "ok",
|
|
246
|
+
"mode": mode,
|
|
247
|
+
"plugin_id": verification.plugin_id,
|
|
248
|
+
"plugin_version": verification.plugin_version,
|
|
249
|
+
"runtime_profile_id": verification.runtime_profile_id,
|
|
250
|
+
"artifact_digest": verification.artifact_digest,
|
|
251
|
+
"package_sha256": verification.package_sha256,
|
|
252
|
+
"signature_verification_status": verification.signature_verification_status,
|
|
253
|
+
"bundle_path": str(resolved_bundle_path),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def install_local_bundle(
|
|
258
|
+
*,
|
|
259
|
+
bundle_path: str | Path,
|
|
260
|
+
plugin_root: str | Path,
|
|
261
|
+
dev_unsigned: bool,
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
resolved_bundle_path = Path(bundle_path).expanduser().resolve()
|
|
264
|
+
resolved_plugin_root = Path(plugin_root).expanduser().resolve()
|
|
265
|
+
verification = shared_verify_bundle(resolved_bundle_path, allow_unsigned_dev=dev_unsigned)
|
|
266
|
+
target_dir = resolved_plugin_root / verification.plugin_id
|
|
267
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
target_bundle_path = target_dir / f"{verification.plugin_id}{SUPPORTED_BUNDLE_SUFFIX}"
|
|
269
|
+
temp_bundle_path = target_bundle_path.with_suffix(f"{target_bundle_path.suffix}.tmp")
|
|
270
|
+
shutil.copy2(resolved_bundle_path, temp_bundle_path)
|
|
271
|
+
temp_bundle_path.replace(target_bundle_path)
|
|
272
|
+
return {
|
|
273
|
+
"install_status": "ok",
|
|
274
|
+
"plugin_id": verification.plugin_id,
|
|
275
|
+
"plugin_version": verification.plugin_version,
|
|
276
|
+
"package_sha256": verification.package_sha256,
|
|
277
|
+
"signature_verification_status": verification.signature_verification_status,
|
|
278
|
+
"installed_bundle": str(target_bundle_path),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def benchmark_bundle(
|
|
283
|
+
*,
|
|
284
|
+
source_dir: str | Path,
|
|
285
|
+
output_dir: str | Path | None = None,
|
|
286
|
+
runtime_profile_id: str = "",
|
|
287
|
+
) -> dict[str, Any]:
|
|
288
|
+
started_at = time.perf_counter()
|
|
289
|
+
build_result = build_bundle(
|
|
290
|
+
source_dir=source_dir,
|
|
291
|
+
output_dir=output_dir,
|
|
292
|
+
runtime_profile_id=runtime_profile_id,
|
|
293
|
+
)
|
|
294
|
+
build_duration_ms = round((time.perf_counter() - started_at) * 1000, 3)
|
|
295
|
+
bundle_path = Path(build_result["bundle_path"])
|
|
296
|
+
inspect_result = inspect_bundle(bundle_path)
|
|
297
|
+
return {
|
|
298
|
+
"benchmark_status": "ok",
|
|
299
|
+
"build_duration_ms": build_duration_ms,
|
|
300
|
+
"bundle_size_bytes": bundle_path.stat().st_size,
|
|
301
|
+
"plugin_id": build_result["plugin_id"],
|
|
302
|
+
"plugin_version": build_result["plugin_version"],
|
|
303
|
+
"runtime_profile_id": build_result["runtime_profile_id"],
|
|
304
|
+
"artifact_digest": inspect_result["artifact_digest"],
|
|
305
|
+
"package_sha256": inspect_result["package_sha256"],
|
|
306
|
+
"bundle_path": str(bundle_path),
|
|
307
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""tg-bot-plugin-buildkit 的命令行入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .buildkit import (
|
|
10
|
+
benchmark_bundle,
|
|
11
|
+
build_bundle,
|
|
12
|
+
inspect_bundle,
|
|
13
|
+
install_local_bundle,
|
|
14
|
+
validate_bundle,
|
|
15
|
+
verify_bundle,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _stable_json(payload: dict[str, object]) -> str:
|
|
20
|
+
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_args() -> argparse.Namespace:
|
|
24
|
+
parser = argparse.ArgumentParser(description="构建并校验 TG-BOT 插件 bundle。")
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
|
|
27
|
+
build_parser = subparsers.add_parser("build", help="构建可复现的 .tgpkg bundle。")
|
|
28
|
+
build_parser.add_argument("--source-dir", required=True, help="插件源码目录。")
|
|
29
|
+
build_parser.add_argument("--output-dir", default="", help="生成 bundle 的输出根目录。")
|
|
30
|
+
build_parser.add_argument("--runtime-profile-id", default="", help="覆盖 runtime profile id。")
|
|
31
|
+
|
|
32
|
+
inspect_parser = subparsers.add_parser("inspect", help="检查 bundle 合同完整性。")
|
|
33
|
+
inspect_parser.add_argument("--bundle", required=True, help=".tgpkg bundle 路径。")
|
|
34
|
+
|
|
35
|
+
verify_parser = subparsers.add_parser("verify", help="校验 bundle 签名材料。")
|
|
36
|
+
verify_parser.add_argument("--bundle", required=True, help=".tgpkg bundle 路径。")
|
|
37
|
+
verify_parser.add_argument(
|
|
38
|
+
"--allow-unsigned-dev",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="允许未签名的开发态 bundle。",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
validate_parser = subparsers.add_parser("validate", help="按 runtime profile 校验 bundle。")
|
|
44
|
+
validate_parser.add_argument("--bundle", required=True, help=".tgpkg bundle 路径。")
|
|
45
|
+
validate_parser.add_argument("--mode", required=True, choices=("same-profile", "target-profile"))
|
|
46
|
+
validate_parser.add_argument("--target-runtime-profile-id", default="", help="target-profile 模式必填。")
|
|
47
|
+
validate_parser.add_argument("--allow-unsigned-dev", action="store_true")
|
|
48
|
+
|
|
49
|
+
benchmark_parser = subparsers.add_parser("benchmark", help="执行一次本地构建基准测试。")
|
|
50
|
+
benchmark_parser.add_argument("--source-dir", required=True, help="插件源码目录。")
|
|
51
|
+
benchmark_parser.add_argument("--output-dir", default="", help="生成 bundle 的输出根目录。")
|
|
52
|
+
benchmark_parser.add_argument("--runtime-profile-id", default="", help="覆盖 runtime profile id。")
|
|
53
|
+
|
|
54
|
+
install_parser = subparsers.add_parser("install-local", help="将 bundle 复制到本地插件根目录。")
|
|
55
|
+
install_parser.add_argument("--bundle", required=True, help=".tgpkg bundle 路径。")
|
|
56
|
+
install_parser.add_argument("--plugin-root", required=True, help="目标 TG_BOT_PLUGIN_ROOT 风格目录。")
|
|
57
|
+
install_parser.add_argument("--dev-unsigned", action="store_true", help="允许未签名的开发态 bundle。")
|
|
58
|
+
|
|
59
|
+
return parser.parse_args()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
args = _parse_args()
|
|
64
|
+
try:
|
|
65
|
+
if args.command == "build":
|
|
66
|
+
payload = build_bundle(
|
|
67
|
+
source_dir=args.source_dir,
|
|
68
|
+
output_dir=args.output_dir,
|
|
69
|
+
runtime_profile_id=args.runtime_profile_id,
|
|
70
|
+
)
|
|
71
|
+
elif args.command == "inspect":
|
|
72
|
+
payload = inspect_bundle(args.bundle)
|
|
73
|
+
elif args.command == "verify":
|
|
74
|
+
payload = verify_bundle(args.bundle, allow_unsigned_dev=bool(args.allow_unsigned_dev))
|
|
75
|
+
elif args.command == "validate":
|
|
76
|
+
payload = validate_bundle(
|
|
77
|
+
args.bundle,
|
|
78
|
+
mode=args.mode,
|
|
79
|
+
target_runtime_profile_id=args.target_runtime_profile_id,
|
|
80
|
+
allow_unsigned_dev=bool(args.allow_unsigned_dev),
|
|
81
|
+
)
|
|
82
|
+
elif args.command == "benchmark":
|
|
83
|
+
payload = benchmark_bundle(
|
|
84
|
+
source_dir=args.source_dir,
|
|
85
|
+
output_dir=args.output_dir,
|
|
86
|
+
runtime_profile_id=args.runtime_profile_id,
|
|
87
|
+
)
|
|
88
|
+
elif args.command == "install-local":
|
|
89
|
+
payload = install_local_bundle(
|
|
90
|
+
bundle_path=args.bundle,
|
|
91
|
+
plugin_root=args.plugin_root,
|
|
92
|
+
dev_unsigned=bool(args.dev_unsigned),
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError(f"unsupported command: {args.command}")
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
print(_stable_json({"status": "error", "message": str(exc)}), end="")
|
|
98
|
+
return 1
|
|
99
|
+
print(_stable_json({"status": "ok", **payload}), end="")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""面向 tg-bot-plugin-contract-core 的本地适配层。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _candidate_src_paths() -> list[Path]:
|
|
12
|
+
candidates: list[Path] = []
|
|
13
|
+
env_path = str(os.getenv("TG_BOT_PLUGIN_CONTRACT_CORE_SRC") or "").strip()
|
|
14
|
+
if env_path:
|
|
15
|
+
candidates.append(Path(env_path).expanduser())
|
|
16
|
+
sibling = Path(__file__).resolve().parents[3] / "tg-bot-plugin-contract-core" / "src"
|
|
17
|
+
candidates.append(sibling)
|
|
18
|
+
deduped: list[Path] = []
|
|
19
|
+
for candidate in candidates:
|
|
20
|
+
resolved = candidate.resolve()
|
|
21
|
+
if resolved not in deduped:
|
|
22
|
+
deduped.append(resolved)
|
|
23
|
+
return deduped
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_contract_core():
|
|
27
|
+
try:
|
|
28
|
+
public_module = import_module("tg_bot_plugin_contract_core")
|
|
29
|
+
core_module = import_module("tg_bot_plugin_contract_core.core")
|
|
30
|
+
return public_module, core_module
|
|
31
|
+
except ImportError:
|
|
32
|
+
for candidate in _candidate_src_paths():
|
|
33
|
+
if not candidate.is_dir():
|
|
34
|
+
continue
|
|
35
|
+
candidate_str = str(candidate)
|
|
36
|
+
if candidate_str not in sys.path:
|
|
37
|
+
sys.path.insert(0, candidate_str)
|
|
38
|
+
try:
|
|
39
|
+
public_module = import_module("tg_bot_plugin_contract_core")
|
|
40
|
+
core_module = import_module("tg_bot_plugin_contract_core.core")
|
|
41
|
+
return public_module, core_module
|
|
42
|
+
except ImportError:
|
|
43
|
+
continue
|
|
44
|
+
raise ImportError(
|
|
45
|
+
"tg_bot_plugin_contract_core is not importable; install the package or set "
|
|
46
|
+
"TG_BOT_PLUGIN_CONTRACT_CORE_SRC to the package src directory",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_PUBLIC_MODULE, _CORE_MODULE = _load_contract_core()
|
|
51
|
+
|
|
52
|
+
ContractCoreError = _PUBLIC_MODULE.ContractCoreError
|
|
53
|
+
compute_artifact_digest = _PUBLIC_MODULE.compute_artifact_digest
|
|
54
|
+
compute_package_sha256 = _PUBLIC_MODULE.compute_package_sha256
|
|
55
|
+
inspect_bundle = _PUBLIC_MODULE.inspect_bundle
|
|
56
|
+
validate_bundle_same_profile = _PUBLIC_MODULE.validate_bundle_same_profile
|
|
57
|
+
validate_bundle_target_profile = _PUBLIC_MODULE.validate_bundle_target_profile
|
|
58
|
+
verify_bundle = _PUBLIC_MODULE.verify_bundle
|
|
59
|
+
write_reproducible_bundle = _PUBLIC_MODULE.write_reproducible_bundle
|
|
60
|
+
normalize_manifest_payload = _CORE_MODULE._normalize_manifest_payload
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import zipfile
|
|
6
|
+
|
|
7
|
+
from tg_bot_plugin_buildkit.buildkit import (
|
|
8
|
+
benchmark_bundle,
|
|
9
|
+
build_bundle,
|
|
10
|
+
inspect_bundle,
|
|
11
|
+
install_local_bundle,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write_manifest(source_dir: Path, *, runtime_profile_id: str = "") -> None:
|
|
16
|
+
payload = {
|
|
17
|
+
"plugin_id": "demo",
|
|
18
|
+
"plugin_version": "1.2.3",
|
|
19
|
+
"name": "demo",
|
|
20
|
+
"description": "demo plugin",
|
|
21
|
+
"category": "utility",
|
|
22
|
+
"runtime_profile_id": runtime_profile_id,
|
|
23
|
+
"artifact_digest": "",
|
|
24
|
+
"entrypoint": {"module_path": "__init__", "symbol": "DemoPlugin"},
|
|
25
|
+
"config_schema": {},
|
|
26
|
+
"interaction_schema": {},
|
|
27
|
+
"declared_scopes": [],
|
|
28
|
+
"declared_capabilities": [],
|
|
29
|
+
}
|
|
30
|
+
(source_dir / "manifest.json").write_text(
|
|
31
|
+
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
32
|
+
encoding="utf-8",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_plugin_source(tmp_path: Path) -> Path:
|
|
37
|
+
source_dir = tmp_path / "plugin"
|
|
38
|
+
source_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
_write_manifest(source_dir)
|
|
40
|
+
(source_dir / "__init__.py").write_text("class DemoPlugin:\n pass\n", encoding="utf-8")
|
|
41
|
+
(source_dir / "META-INF").mkdir(parents=True, exist_ok=True)
|
|
42
|
+
return source_dir
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_build_bundle_writes_canonical_filename(tmp_path: Path):
|
|
46
|
+
source_dir = _make_plugin_source(tmp_path)
|
|
47
|
+
|
|
48
|
+
result = build_bundle(
|
|
49
|
+
source_dir=source_dir,
|
|
50
|
+
output_dir=tmp_path / "dist" / "plugins",
|
|
51
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
bundle_path = Path(result["bundle_path"])
|
|
55
|
+
assert bundle_path.name == "demo-1.2.3-cpython-3.13-linux-x86_64-gnu.tgpkg"
|
|
56
|
+
assert bundle_path.is_file()
|
|
57
|
+
assert result["package_sha256"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_inspect_bundle_round_trips_build_output(tmp_path: Path):
|
|
61
|
+
source_dir = _make_plugin_source(tmp_path)
|
|
62
|
+
build_result = build_bundle(
|
|
63
|
+
source_dir=source_dir,
|
|
64
|
+
output_dir=tmp_path / "dist" / "plugins",
|
|
65
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
inspect_result = inspect_bundle(build_result["bundle_path"])
|
|
69
|
+
|
|
70
|
+
assert inspect_result["plugin_id"] == "demo"
|
|
71
|
+
assert inspect_result["plugin_version"] == "1.2.3"
|
|
72
|
+
assert inspect_result["package_sha256"] == build_result["package_sha256"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_install_local_bundle_supports_dev_unsigned(tmp_path: Path):
|
|
76
|
+
source_dir = _make_plugin_source(tmp_path)
|
|
77
|
+
build_result = build_bundle(
|
|
78
|
+
source_dir=source_dir,
|
|
79
|
+
output_dir=tmp_path / "dist" / "plugins",
|
|
80
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
81
|
+
)
|
|
82
|
+
plugin_root = tmp_path / "plugins"
|
|
83
|
+
|
|
84
|
+
install_result = install_local_bundle(
|
|
85
|
+
bundle_path=build_result["bundle_path"],
|
|
86
|
+
plugin_root=plugin_root,
|
|
87
|
+
dev_unsigned=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert install_result["install_status"] == "ok"
|
|
91
|
+
assert (plugin_root / "demo" / "demo.tgpkg").is_file()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_benchmark_bundle_reports_size_and_duration(tmp_path: Path):
|
|
95
|
+
source_dir = _make_plugin_source(tmp_path)
|
|
96
|
+
|
|
97
|
+
result = benchmark_bundle(
|
|
98
|
+
source_dir=source_dir,
|
|
99
|
+
output_dir=tmp_path / "dist" / "plugins",
|
|
100
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert result["benchmark_status"] == "ok"
|
|
104
|
+
assert result["bundle_size_bytes"] > 0
|
|
105
|
+
assert result["build_duration_ms"] >= 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_build_bundle_excludes_repo_metadata_and_default_dist(tmp_path: Path):
|
|
109
|
+
source_dir = _make_plugin_source(tmp_path)
|
|
110
|
+
(source_dir / ".git").mkdir(parents=True, exist_ok=True)
|
|
111
|
+
(source_dir / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
|
112
|
+
(source_dir / ".github" / "workflows").mkdir(parents=True, exist_ok=True)
|
|
113
|
+
(source_dir / ".github" / "workflows" / "ci.yml").write_text("name: ci\n", encoding="utf-8")
|
|
114
|
+
(source_dir / ".venv" / "bin").mkdir(parents=True, exist_ok=True)
|
|
115
|
+
(source_dir / ".venv" / "bin" / "python").write_text("#!/usr/bin/env python3\n", encoding="utf-8")
|
|
116
|
+
(source_dir / "tests").mkdir(parents=True, exist_ok=True)
|
|
117
|
+
(source_dir / "tests" / "test_demo.py").write_text("def test_demo():\n assert True\n", encoding="utf-8")
|
|
118
|
+
(source_dir / "scripts").mkdir(parents=True, exist_ok=True)
|
|
119
|
+
(source_dir / "scripts" / "helper.py").write_text("print('helper')\n", encoding="utf-8")
|
|
120
|
+
(source_dir / "README.md").write_text("# demo\n", encoding="utf-8")
|
|
121
|
+
(source_dir / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
first = build_bundle(
|
|
124
|
+
source_dir=source_dir,
|
|
125
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
126
|
+
)
|
|
127
|
+
second = build_bundle(
|
|
128
|
+
source_dir=source_dir,
|
|
129
|
+
runtime_profile_id="cpython-3.13-linux-x86_64-gnu",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
assert first["package_sha256"] == second["package_sha256"]
|
|
133
|
+
with zipfile.ZipFile(first["bundle_path"]) as archive:
|
|
134
|
+
members = set(archive.namelist())
|
|
135
|
+
|
|
136
|
+
assert "manifest.json" in members
|
|
137
|
+
assert "__init__.py" in members
|
|
138
|
+
assert all(not member.startswith(".git/") for member in members)
|
|
139
|
+
assert all(not member.startswith(".github/") for member in members)
|
|
140
|
+
assert all(not member.startswith(".venv/") for member in members)
|
|
141
|
+
assert all(not member.startswith("dist/") for member in members)
|
|
142
|
+
assert all(not member.startswith("scripts/") for member in members)
|
|
143
|
+
assert all(not member.startswith("tests/") for member in members)
|
|
144
|
+
assert "README.md" not in members
|
|
145
|
+
assert "pyproject.toml" not in members
|