@@ -114,28 +114,36 @@ jobs:
114114 runner : ubuntu-24.04
115115 os : linux
116116 arch : amd64
117+ pbs_target : x86_64-unknown-linux-gnu
117118 - name : linux-arm64
118119 runner : ubuntu-24.04-arm
119120 os : linux
120121 arch : arm64
122+ pbs_target : aarch64-unknown-linux-gnu
121123 - name : windows-x64
122124 runner : windows-2022
123125 os : win
124126 arch : amd64
127+ pbs_target : x86_64-pc-windows-msvc
125128 - name : windows-arm64
126129 runner : windows-11-arm
127130 os : win
128131 arch : arm64
132+ pbs_target : aarch64-pc-windows-msvc
129133 - name : macos-x64
130134 runner : macos-15-intel
131135 os : mac
132136 arch : amd64
137+ pbs_target : x86_64-apple-darwin
133138 - name : macos-arm64
134139 runner : macos-15
135140 os : mac
136141 arch : arm64
142+ pbs_target : aarch64-apple-darwin
137143 env :
138144 CSC_IDENTITY_AUTO_DISCOVERY : " false"
145+ PYTHON_BUILD_STANDALONE_RELEASE : " 20260211"
146+ PYTHON_BUILD_STANDALONE_VERSION : " 3.12.12"
139147 steps :
140148 - name : Checkout repository
141149 uses : actions/checkout@v6
@@ -172,6 +180,7 @@ jobs:
172180 env :
173181 RUNNER_TEMP_DIR : ${{ runner.temp }}
174182 SETUP_PYTHON_PATH : ${{ steps.setup-python.outputs.python-path }}
183+ PYTHON_BUILD_STANDALONE_TARGET : ${{ matrix.pbs_target }}
175184 run : |
176185 if [ -z "$SETUP_PYTHON_PATH" ]; then
177186 echo "actions/setup-python did not return python-path output." >&2
@@ -183,32 +192,28 @@ jobs:
183192 import shutil
184193 import subprocess
185194 import sys
186-
187- executable = pathlib.Path(sys.executable).resolve()
188- candidate_roots = []
189- if sys.platform == "win32":
190- candidate_roots.extend([executable.parent, executable.parent.parent])
191- else:
192- candidate_roots.extend([executable.parent.parent, executable.parent])
193-
194- def is_runtime_root(root: pathlib.Path) -> bool:
195- if sys.platform == "win32":
196- return (root / "python.exe").is_file() or (
197- root / "Scripts" / "python.exe"
198- ).is_file()
199- return (root / "bin" / "python3").is_file() or (
200- root / "bin" / "python"
201- ).is_file()
202-
203- source_runtime_root = next(
204- (candidate for candidate in candidate_roots if is_runtime_root(candidate)),
205- None,
206- )
207- if source_runtime_root is None:
195+ import tarfile
196+ import time
197+ import urllib.parse
198+ import urllib.request
199+
200+ release = (os.environ.get("PYTHON_BUILD_STANDALONE_RELEASE") or "").strip()
201+ version = (os.environ.get("PYTHON_BUILD_STANDALONE_VERSION") or "").strip()
202+ target = (os.environ.get("PYTHON_BUILD_STANDALONE_TARGET") or "").strip()
203+ if not release or not version or not target:
208204 raise RuntimeError(
209- f"Cannot resolve runtime root from executable: {executable}"
205+ "Missing python-build-standalone selection envs: "
206+ "PYTHON_BUILD_STANDALONE_RELEASE / PYTHON_BUILD_STANDALONE_VERSION / PYTHON_BUILD_STANDALONE_TARGET."
210207 )
211208
209+ asset_name = (
210+ f"cpython-{version}+{release}-{target}-install_only_stripped.tar.gz"
211+ )
212+ asset_url = (
213+ "https://github.com/astral-sh/python-build-standalone/releases/download/"
214+ f"{release}/{urllib.parse.quote(asset_name)}"
215+ )
216+
212217 target_runtime_root = (
213218 pathlib.Path(
214219 os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"]
@@ -217,99 +222,50 @@ jobs:
217222 )
218223 if target_runtime_root.exists():
219224 shutil.rmtree(target_runtime_root)
225+
226+ download_archive_path = pathlib.Path(
227+ os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"]
228+ ) / asset_name
229+ extract_root = pathlib.Path(
230+ os.environ.get("RUNNER_TEMP_DIR") or os.environ["RUNNER_TEMP"]
231+ ) / "astrbot-cpython-runtime-extract"
232+ if extract_root.exists():
233+ shutil.rmtree(extract_root)
234+ extract_root.mkdir(parents=True, exist_ok=True)
235+
236+ last_error = None
237+ for attempt in range(1, 4):
238+ try:
239+ with urllib.request.urlopen(asset_url, timeout=180) as response:
240+ with download_archive_path.open("wb") as output:
241+ shutil.copyfileobj(response, output)
242+ break
243+ except Exception as exc:
244+ last_error = exc
245+ if attempt >= 3:
246+ raise RuntimeError(
247+ f"Failed to download python-build-standalone asset: {asset_url}"
248+ ) from exc
249+ time.sleep(attempt * 2)
250+ if last_error and not download_archive_path.exists():
251+ raise RuntimeError(
252+ f"Failed to download python-build-standalone asset: {asset_url}"
253+ ) from last_error
254+
255+ with tarfile.open(download_archive_path, "r:gz") as archive:
256+ archive.extractall(extract_root)
257+
258+ source_runtime_root = extract_root / "python"
259+ if not source_runtime_root.is_dir():
260+ raise RuntimeError(
261+ "Invalid python-build-standalone archive layout: missing top-level python/ directory."
262+ )
220263 shutil.copytree(
221264 source_runtime_root,
222265 target_runtime_root,
223266 symlinks=sys.platform != "win32",
224267 )
225268
226- if sys.platform == "darwin":
227- probe_binary = target_runtime_root / "bin" / "python3"
228- if not probe_binary.is_file():
229- raise RuntimeError(
230- f"Cannot find runtime probe binary: {probe_binary}"
231- )
232-
233- probe = subprocess.run(
234- ["otool", "-L", str(probe_binary)],
235- stdout=subprocess.PIPE,
236- stderr=subprocess.PIPE,
237- text=True,
238- check=False,
239- )
240- if probe.returncode != 0:
241- raise RuntimeError(
242- f"Failed to inspect runtime binary by otool: {probe.stderr.strip()}"
243- )
244-
245- old_dependency = None
246- for line in probe.stdout.splitlines()[1:]:
247- candidate = line.strip().split(" (", 1)[0]
248- if (
249- candidate.startswith(
250- "/Library/Frameworks/Python.framework/Versions/"
251- )
252- and candidate.endswith("/Python")
253- ):
254- old_dependency = candidate
255- break
256-
257- if old_dependency:
258- patched = 0
259- for file_path in target_runtime_root.rglob("*"):
260- if not file_path.is_file():
261- continue
262- file_mode = file_path.stat().st_mode
263- if (
264- file_path.name != "Python"
265- and file_path.suffix not in {".dylib", ".so"}
266- and (file_mode & 0o111) == 0
267- ):
268- continue
269- inspected = subprocess.run(
270- ["otool", "-L", str(file_path)],
271- stdout=subprocess.PIPE,
272- stderr=subprocess.DEVNULL,
273- text=True,
274- check=False,
275- )
276- if (
277- inspected.returncode != 0
278- or old_dependency not in inspected.stdout
279- ):
280- continue
281-
282- relative_libpython = pathlib.Path(
283- os.path.relpath(
284- target_runtime_root / "Python", file_path.parent
285- )
286- ).as_posix()
287- new_dependency = f"@loader_path/{relative_libpython}"
288-
289- subprocess.run(
290- [
291- "install_name_tool",
292- "-change",
293- old_dependency,
294- new_dependency,
295- str(file_path),
296- ],
297- check=True,
298- )
299- subprocess.run(
300- ["codesign", "--force", "--sign", "-", str(file_path)],
301- check=True,
302- stdout=subprocess.DEVNULL,
303- stderr=subprocess.DEVNULL,
304- )
305- patched += 1
306-
307- if patched == 0:
308- raise RuntimeError(
309- "Detected absolute Python.framework linkage on macOS runtime, "
310- "but failed to patch any binary."
311- )
312-
313269 if sys.platform == "win32":
314270 verify_candidates = [
315271 target_runtime_root / "python.exe",
@@ -338,15 +294,33 @@ jobs:
338294 )
339295 if verify.returncode != 0:
340296 raise RuntimeError(
341- "Relocated runtime probe failed: "
297+ "Packaged runtime probe failed: "
342298 + (
343299 verify.stderr.strip()
344300 or verify.stdout.strip()
345301 or f"exit={verify.returncode}"
346302 )
347303 )
348304
305+ ssl_verify = subprocess.run(
306+ [str(verify_binary), "-c", "import ssl"],
307+ stdout=subprocess.PIPE,
308+ stderr=subprocess.PIPE,
309+ text=True,
310+ check=False,
311+ )
312+ if ssl_verify.returncode != 0:
313+ raise RuntimeError(
314+ "Packaged runtime ssl probe failed: "
315+ + (
316+ ssl_verify.stderr.strip()
317+ or ssl_verify.stdout.strip()
318+ or f"exit={ssl_verify.returncode}"
319+ )
320+ )
321+
349322 print(f"ASTRBOT_DESKTOP_CPYTHON_HOME={target_runtime_root}")
323+ print(f"ASTRBOT_DESKTOP_CPYTHON_ASSET={asset_name}")
350324 PY
351325
352326 - name : Setup pnpm
@@ -430,15 +404,15 @@ jobs:
430404 )
431405
432406 module_check = subprocess.run(
433- [str(runtime_python), "-c", "import aiohttp"],
407+ [str(runtime_python), "-c", "import ssl, aiohttp"],
434408 stdout=subprocess.PIPE,
435409 stderr=subprocess.PIPE,
436410 text=True,
437411 check=False,
438412 )
439413 if module_check.returncode != 0:
440414 raise RuntimeError(
441- "Packaged runtime dependency smoke test failed (import aiohttp): "
415+ "Packaged runtime dependency smoke test failed (import ssl, aiohttp): "
442416 + (module_check.stderr.strip() or module_check.stdout.strip())
443417 )
444418
0 commit comments