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.
@@ -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,5 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ __pycache__/
4
+ dist/
5
+ *.pyc
@@ -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