2026-07-13 16:34:09 +08:00
|
|
|
#!/usr/bin/env python3
|
2026-07-13 17:49:01 +08:00
|
|
|
"""Stage and build one Python wheel at the repository version."""
|
2026-07-13 16:34:09 +08:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import email
|
2026-07-13 17:49:01 +08:00
|
|
|
import json
|
2026-07-13 16:34:09 +08:00
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import shutil
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
import zipfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
2026-08-10 19:45:54 +08:00
|
|
|
SDK_DISTRIBUTION = "deepseek-harness-sdk"
|
|
|
|
|
RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin"
|
2026-08-11 20:09:33 +08:00
|
|
|
PLATFORM_MANIFEST = ROOT / "python" / "sdk-runtime" / "platforms.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]]:
|
|
|
|
|
"""Load the release platform tag and executable pairs from the build manifest."""
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(path.read_text())
|
|
|
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
|
|
|
raise ValueError(f"could not read runtime platform manifest from {path}") from error
|
|
|
|
|
if not isinstance(payload, dict) or not payload:
|
|
|
|
|
raise ValueError(f"{path} must contain a non-empty platform object")
|
|
|
|
|
platforms: dict[str, tuple[str, str]] = {}
|
|
|
|
|
for name, raw in payload.items():
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(name, str)
|
|
|
|
|
or not isinstance(raw, dict)
|
|
|
|
|
or set(raw) != {"tag", "executable"}
|
|
|
|
|
or not isinstance(raw["tag"], str)
|
|
|
|
|
or not isinstance(raw["executable"], str)
|
|
|
|
|
):
|
|
|
|
|
raise ValueError(f"{path} platform entries must contain string tag and executable fields")
|
|
|
|
|
platforms[name] = (raw["tag"], raw["executable"])
|
|
|
|
|
return platforms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PLATFORMS = load_platforms()
|
2026-07-30 01:29:18 +08:00
|
|
|
|
|
|
|
|
|
feat(python-runtime): package the Windows x64 dsh executable
Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build.
Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted.
The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure.
2026-08-23 15:39:18 +08:00
|
|
|
def runtime_filenames(executable_name: str) -> tuple[str, ...]:
|
|
|
|
|
"""Return the exact platform payload names for one runtime executable."""
|
|
|
|
|
if executable_name.endswith(".exe"):
|
|
|
|
|
return (executable_name, f"{executable_name.removesuffix('.exe')}-rg.exe")
|
|
|
|
|
names = (executable_name, f"{executable_name}-rg")
|
|
|
|
|
return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names
|
2026-07-30 00:08:17 +08:00
|
|
|
|
|
|
|
|
|
2026-07-13 16:34:09 +08:00
|
|
|
def main() -> None:
|
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
|
|
|
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
|
2026-07-13 17:49:01 +08:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--tag",
|
2026-08-11 14:27:59 +08:00
|
|
|
help="optional python-v<repository-version> release tag; it must match package.json",
|
2026-07-13 17:49:01 +08:00
|
|
|
)
|
2026-07-13 16:34:09 +08:00
|
|
|
parser.add_argument("--output-dir", type=Path, required=True)
|
|
|
|
|
parser.add_argument("--platform", choices=tuple(PLATFORMS))
|
|
|
|
|
parser.add_argument("--runtime-exe", type=Path)
|
|
|
|
|
args = parser.parse_args()
|
2026-07-13 17:49:01 +08:00
|
|
|
version = repository_version()
|
|
|
|
|
validate_release_tag(args.tag, version)
|
2026-08-11 03:20:16 +08:00
|
|
|
# Wheels carry the PEP 440 spelling; the tag keeps the repository spelling.
|
|
|
|
|
wheel_version = pep440_version(version)
|
2026-07-13 16:34:09 +08:00
|
|
|
if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
|
|
|
|
|
parser.error("runtime builds require --platform and --runtime-exe")
|
|
|
|
|
if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
|
|
|
|
|
parser.error("SDK builds do not accept --platform or --runtime-exe")
|
|
|
|
|
|
|
|
|
|
output_dir = args.output_dir.resolve()
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
|
|
|
|
|
staging = Path(temporary) / args.package
|
|
|
|
|
if args.package == "sdk":
|
2026-08-11 03:20:16 +08:00
|
|
|
stage_sdk(staging, wheel_version)
|
2026-07-13 16:34:09 +08:00
|
|
|
environment = None
|
2026-08-11 03:20:16 +08:00
|
|
|
expected = output_dir / f"deepseek_harness_sdk-{wheel_version}-py3-none-any.whl"
|
2026-07-13 16:34:09 +08:00
|
|
|
else:
|
|
|
|
|
platform_tag, executable_name = PLATFORMS[args.platform]
|
2026-08-11 03:20:16 +08:00
|
|
|
stage_runtime(staging, wheel_version, args.runtime_exe.resolve(), executable_name)
|
2026-07-13 16:34:09 +08:00
|
|
|
environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
|
2026-08-11 03:20:16 +08:00
|
|
|
expected = output_dir / f"deepseek_harness_runtime_bin-{wheel_version}-py3-none-{platform_tag}.whl"
|
2026-07-13 16:34:09 +08:00
|
|
|
command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
|
|
|
|
|
subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
|
|
|
|
|
if not expected.is_file():
|
|
|
|
|
raise RuntimeError(f"build did not produce expected wheel: {expected}")
|
2026-08-11 03:20:16 +08:00
|
|
|
verify_wheel(expected, args.package, wheel_version, None if args.platform is None else PLATFORMS[args.platform])
|
2026-07-13 16:34:09 +08:00
|
|
|
print(expected)
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 17:49:01 +08:00
|
|
|
def repository_version(root: Path = ROOT) -> str:
|
|
|
|
|
package_json = root / "package.json"
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(package_json.read_text())
|
|
|
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
|
|
|
raise ValueError(f"could not read repository version from {package_json}") from error
|
|
|
|
|
version = payload.get("version") if isinstance(payload, dict) else None
|
2026-08-11 03:20:16 +08:00
|
|
|
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version) is None:
|
2026-07-13 17:49:01 +08:00
|
|
|
raise ValueError(
|
2026-08-11 03:20:16 +08:00
|
|
|
f"{package_json} version must be X.Y.Z with an optional prerelease segment, got {version!r}"
|
2026-07-13 17:49:01 +08:00
|
|
|
)
|
|
|
|
|
return version
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 03:20:16 +08:00
|
|
|
def pep440_version(version: str) -> str:
|
|
|
|
|
"""The Python spelling of a repository version.
|
|
|
|
|
|
|
|
|
|
A release candidate is `0.0.1-rc.1` in the repository and `0.0.1rc1` under
|
|
|
|
|
PEP 440. Build backends normalize to the latter, so the wheel filename and
|
|
|
|
|
metadata carry it: comparing them against the repository spelling would
|
|
|
|
|
reject every prerelease build.
|
|
|
|
|
"""
|
|
|
|
|
stable, separator, prerelease = version.partition("-")
|
|
|
|
|
if not separator:
|
|
|
|
|
return stable
|
|
|
|
|
match = re.fullmatch(r"(a|b|c|rc|alpha|beta|pre|preview)\.?(\d+)", prerelease)
|
|
|
|
|
if match is None:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"prerelease segment {prerelease!r} has no PEP 440 spelling; use rc.N, alpha.N, or beta.N"
|
|
|
|
|
)
|
|
|
|
|
identifier = {"alpha": "a", "beta": "b", "c": "rc", "pre": "rc", "preview": "rc"}.get(
|
|
|
|
|
match.group(1), match.group(1)
|
|
|
|
|
)
|
|
|
|
|
return f"{stable}{identifier}{match.group(2)}"
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 17:49:01 +08:00
|
|
|
def validate_release_tag(tag: str | None, version: str) -> None:
|
|
|
|
|
if tag is None:
|
|
|
|
|
return
|
|
|
|
|
expected = f"python-v{version}"
|
|
|
|
|
if tag != expected:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"release tag must match repository version: expected {expected!r}, got {tag!r}"
|
|
|
|
|
)
|
2026-07-13 16:34:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def copy_package(source: Path, destination: Path) -> None:
|
|
|
|
|
shutil.copytree(
|
|
|
|
|
source,
|
|
|
|
|
destination,
|
|
|
|
|
ignore=shutil.ignore_patterns(
|
|
|
|
|
".venv",
|
|
|
|
|
".pytest_cache",
|
|
|
|
|
"__pycache__",
|
|
|
|
|
"*.pyc",
|
|
|
|
|
"dist",
|
|
|
|
|
"node_modules",
|
feat(python-runtime): package the dsh CLI and profile assets
Make the zero-code dsh-python-runtime-closure depend on the real @deepseek-ai/dsh application and every required profile peer, then package apps/cli's built bin instead of the deleted Python carrier. Rename executables to deepseek-harness-sdk-runtime-<platform>-<arch>, update wheel/platform/build workflow discovery, and install a Python dsh console command that requires explicit DSH_HOME before exec.
Include profile, bundle, preset, native addon, and shared-library assets needed by the full CLI. Remove the checked-in default cordis.yml and preserve the existing wheel distribution names, Python module names, sidecar validation, and wire identity. Runtime resolution and release tests pin the new artifacts and dev Node carrier.
2026-08-23 14:56:02 +08:00
|
|
|
"deepseek-harness-sdk-runtime-*",
|
2026-07-13 16:34:09 +08:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rewrite_version(pyproject: Path, version: str) -> None:
|
|
|
|
|
text, count = re.subn(
|
|
|
|
|
r'^version = "[^"]+"$',
|
|
|
|
|
f'version = "{version}"',
|
|
|
|
|
pyproject.read_text(),
|
|
|
|
|
count=1,
|
|
|
|
|
flags=re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
if count != 1:
|
|
|
|
|
raise RuntimeError(f"could not rewrite version in {pyproject}")
|
|
|
|
|
pyproject.write_text(text)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 14:27:59 +08:00
|
|
|
def stage_license_files(destination: Path, *, include_notices: bool) -> None:
|
|
|
|
|
"""Copy legal files and declare them as wheel license payloads."""
|
|
|
|
|
shutil.copy2(ROOT / "LICENSE", destination / "LICENSE")
|
|
|
|
|
license_files = '["LICENSE"]'
|
|
|
|
|
if include_notices:
|
|
|
|
|
shutil.copy2(ROOT / "THIRD_PARTY_NOTICES.md", destination / "THIRD_PARTY_NOTICES.md")
|
|
|
|
|
license_files = '["LICENSE", "THIRD_PARTY_NOTICES.md"]'
|
|
|
|
|
pyproject = destination / "pyproject.toml"
|
|
|
|
|
text, count = re.subn(
|
|
|
|
|
r'^(license = "[^"]+")$',
|
|
|
|
|
rf"\1\nlicense-files = {license_files}",
|
|
|
|
|
pyproject.read_text(),
|
|
|
|
|
count=1,
|
|
|
|
|
flags=re.MULTILINE,
|
|
|
|
|
)
|
|
|
|
|
if count != 1:
|
|
|
|
|
raise RuntimeError(f"could not declare license files in {pyproject}")
|
|
|
|
|
pyproject.write_text(text)
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 16:34:09 +08:00
|
|
|
def stage_sdk(destination: Path, version: str) -> None:
|
|
|
|
|
copy_package(ROOT / "python" / "sdk", destination)
|
2026-08-11 14:27:59 +08:00
|
|
|
stage_license_files(destination, include_notices=False)
|
2026-07-13 16:34:09 +08:00
|
|
|
pyproject = destination / "pyproject.toml"
|
|
|
|
|
rewrite_version(pyproject, version)
|
|
|
|
|
text, count = re.subn(
|
|
|
|
|
r'"deepseek-harness-runtime-bin==[^"]+"',
|
|
|
|
|
f'"deepseek-harness-runtime-bin=={version}"',
|
|
|
|
|
pyproject.read_text(),
|
|
|
|
|
count=1,
|
|
|
|
|
)
|
|
|
|
|
if count != 1:
|
|
|
|
|
raise RuntimeError("SDK must contain exactly one runtime dependency pin")
|
|
|
|
|
pyproject.write_text(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None:
|
|
|
|
|
copy_package(ROOT / "python" / "sdk-runtime", destination)
|
2026-08-11 14:27:59 +08:00
|
|
|
stage_license_files(destination, include_notices=True)
|
2026-07-13 16:34:09 +08:00
|
|
|
rewrite_version(destination / "pyproject.toml", version)
|
|
|
|
|
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
|
|
|
|
|
runtime_dir.mkdir(parents=True, exist_ok=True)
|
feat(python-runtime): package the Windows x64 dsh executable
Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build.
Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted.
The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure.
2026-08-23 15:39:18 +08:00
|
|
|
source_directory = executable.parent
|
|
|
|
|
for filename in runtime_filenames(executable_name):
|
|
|
|
|
shutil.copy2(source_directory / filename, runtime_dir / filename)
|
2026-07-13 16:34:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_wheel(
|
|
|
|
|
wheel: Path,
|
|
|
|
|
package: str,
|
|
|
|
|
version: str,
|
|
|
|
|
platform: tuple[str, str] | None,
|
|
|
|
|
) -> None:
|
|
|
|
|
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
|
|
|
|
|
with zipfile.ZipFile(wheel) as archive:
|
|
|
|
|
wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))
|
|
|
|
|
metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
|
|
|
|
|
wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path))
|
|
|
|
|
metadata = email.message_from_bytes(archive.read(metadata_path))
|
|
|
|
|
if wheel_metadata.get_all("Tag") != [expected_tag]:
|
|
|
|
|
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
|
|
|
|
|
if metadata.get("Version") != version:
|
|
|
|
|
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
|
2026-08-10 19:45:54 +08:00
|
|
|
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
|
|
|
|
|
if metadata.get("Name") != expected_distribution:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}"
|
|
|
|
|
)
|
2026-08-13 01:46:57 +08:00
|
|
|
if metadata.get("License-Expression") != "MIT":
|
2026-08-11 14:27:59 +08:00
|
|
|
raise RuntimeError(
|
2026-08-13 01:46:57 +08:00
|
|
|
f"{wheel} has license expression {metadata.get('License-Expression')}, expected MIT"
|
2026-08-11 14:27:59 +08:00
|
|
|
)
|
|
|
|
|
expected_license_files = ["LICENSE"] if package == "sdk" else ["LICENSE", "THIRD_PARTY_NOTICES.md"]
|
|
|
|
|
license_files = [Path(name).name for name in metadata.get_all("License-File") or []]
|
|
|
|
|
if license_files != expected_license_files:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"{wheel} has license files {license_files}, expected {expected_license_files}"
|
|
|
|
|
)
|
2026-07-29 14:12:27 +08:00
|
|
|
runtime_files = [
|
feat(python-runtime): package the dsh CLI and profile assets
Make the zero-code dsh-python-runtime-closure depend on the real @deepseek-ai/dsh application and every required profile peer, then package apps/cli's built bin instead of the deleted Python carrier. Rename executables to deepseek-harness-sdk-runtime-<platform>-<arch>, update wheel/platform/build workflow discovery, and install a Python dsh console command that requires explicit DSH_HOME before exec.
Include profile, bundle, preset, native addon, and shared-library assets needed by the full CLI. Remove the checked-in default cordis.yml and preserve the existing wheel distribution names, Python module names, sidecar validation, and wire identity. Runtime resolution and release tests pin the new artifacts and dev Node carrier.
2026-08-23 14:56:02 +08:00
|
|
|
name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name
|
2026-07-29 14:12:27 +08:00
|
|
|
]
|
2026-07-13 16:34:09 +08:00
|
|
|
if package == "runtime":
|
|
|
|
|
assert platform is not None
|
feat(python-runtime): package the Windows x64 dsh executable
Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build.
Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted.
The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure.
2026-08-23 15:39:18 +08:00
|
|
|
expected_files = sorted(runtime_filenames(platform[1]))
|
2026-07-30 01:09:54 +08:00
|
|
|
found_files = sorted(Path(name).name for name in runtime_files)
|
|
|
|
|
if found_files != expected_files:
|
|
|
|
|
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
|
|
|
|
|
for runtime_file in runtime_files:
|
|
|
|
|
mode = archive.getinfo(runtime_file).external_attr >> 16
|
feat(python-runtime): package the Windows x64 dsh executable
Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build.
Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted.
The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure.
2026-08-23 15:39:18 +08:00
|
|
|
if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0:
|
2026-07-30 01:09:54 +08:00
|
|
|
raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}")
|
2026-07-29 14:12:27 +08:00
|
|
|
elif runtime_files:
|
|
|
|
|
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
|
2026-07-13 16:34:09 +08:00
|
|
|
if package == "sdk":
|
|
|
|
|
requirements = metadata.get_all("Requires-Dist") or []
|
2026-08-10 19:45:54 +08:00
|
|
|
expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}"
|
2026-07-13 16:34:09 +08:00
|
|
|
if expected_requirement not in requirements:
|
|
|
|
|
raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|