From 69b1e315b476aa714865b58b59cff2832d3d318a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:21:26 +0800 Subject: [PATCH] ci: use isolated self-hosted Windows Python runtime builds --- ...ython-runtime-windows-selfhosted.i18n.yaml | 6 + ...09-06-python-runtime-windows-selfhosted.md | 40 +++++++ ...06-python-runtime-windows-selfhosted.zh.md | 40 +++++++ .../workflows/build-exe-for-python-sdk.yml | 56 ++++++++- python/development.i18n.yaml | 4 +- python/development.md | 2 + python/development.zh.md | 2 + scripts/python-runtime-selfhosted.spec.ts | 109 ++++++++++++++++++ scripts/setup-python-runtime-windows.ps1 | 53 +++++++++ 9 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md create mode 100644 .agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.zh.md create mode 100644 scripts/python-runtime-selfhosted.spec.ts create mode 100644 scripts/setup-python-runtime-windows.ps1 diff --git a/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.i18n.yaml b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.i18n.yaml new file mode 100644 index 0000000000..a3c173532e --- /dev/null +++ b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md +2026-09-06-python-runtime-windows-selfhosted.md: f95389bb8b4d8de58dc6b4e8bad834836c6b325e +2026-09-06-python-runtime-windows-selfhosted.zh.md: 67accee54140d71f31acb43c02d0e3692070d529 diff --git a/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md new file mode 100644 index 0000000000..f95389bb8b --- /dev/null +++ b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md @@ -0,0 +1,40 @@ +# Agent Note: Job-private Windows Python runtime CI + +Status: proposed + +English | [中文](2026-09-06-python-runtime-windows-selfhosted.zh.md) + +## Problem + +The native Python runtime matrix consumes hosted Windows capacity, but moving its build unchanged onto shared persistent runners would modify machine installation state and reuse user-level caches. The [CI failover runbook](../../implemented/process/2026-07-26-ci-failover-runbook.md) remains the owner of the existing general-purpose lanes and pool prerequisites; this proposal covers only Python runtime builds. + +The [read-only prerequisite probe](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34012679056) found native Windows x64, Python 3.14.7 with venv/ensurepip, and enabled Developer Mode, but no Python toolcache. Linux lacked Docker, which both manylinux steps require. These observations permit a Windows-only experiment, not a claim that the runtime build passes. + +## Proposal + +Route only the Windows x64 target in [the runtime workflow](../../../../.github/workflows/build-exe-for-python-sdk.yml) to the persistent pool when `inputs.ci && !inputs.release`, the repository is the canonical repository, and the event is either a same-repository non-fork, non-Dependabot PR or a master push. `DSH_CI_FAILOVER_WINDOWS=selfhosted` enables this routing; an unset or different value keeps the lane hosted. Release/manual builds, other events, Linux/macOS targets, planning, and the SDK-wheel job remain hosted. The implementation is pending native runtime validation. + +The [native setup probe](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34013261224/job/101432611073) downloads Python 3.10.20, verifies command resolution and a seeded smoke venv, asserts unchanged registered Python installations and Developer Mode, and proves job-root deletion. Windows recursive removal needs bounded retries after an observed non-empty-directory failure. The workflow additionally clears the exported compile-cache path and resets temporary-directory variables before action post-steps; focused tests pin those assignments, which are not part of the cited probe commit. The focused routing tests pass, and an inverted failover condition produces three expected failures before restoration. Full executable, wheel, and live-API validation remains pending. + +The [private setup script](../../../../scripts/setup-python-runtime-windows.ps1) bootstraps uv 0.11.23 inside a temporary venv using the preinstalled interpreter, then downloads managed Python 3.10 into a unique job directory with `--no-bin --no-registry`. It creates a seeded tooling venv without further Python downloads. These flags exist in the [pinned uv source](https://github.com/astral-sh/uv/blob/3cdf50e0924f1ace7a92ddbac98b12a958b87688/crates/uv-cli/src/lib.rs#L6672-L6713); the [implementation](https://github.com/astral-sh/uv/blob/3cdf50e0924f1ace7a92ddbac98b12a958b87688/crates/uv/src/commands/python/install.rs#L667-L723) suppresses executable links and registry registration. CI checks Developer Mode rather than enabling it. + +The job owns its pnpm store, pkg/npm/node-gyp/Python/Node caches and temporary test directories. Dependency imports use copy rather than links into a shared store; hosted cache restore/save steps are skipped. An always-run cleanup removes only the recorded job root. Checkout does not persist credentials. These are resource-isolation measures, not protection against malicious code running under the same Windows account. + +## Alternatives considered + +**Cold setup-python with a private toolcache.** Rejected: the concrete Python 3.10.11 [Windows release installer](https://github.com/actions/python-versions/blob/98e79473eb342d6f43487a289ca633620404742e/installers/win-setup-template.ps1#L21-L70) removes matching machine/current-user installation records and installs for all users. A private directory does not isolate that registry state. + +**Administrator-preprovisioned Python 3.10.** Viable with enforced cache-hit-only use and private dependency environments, but the measured pool does not supply it. Portable uv avoids requiring a host installation change. + +**Migrate Linux simultaneously.** Deferred until administrator-approved Docker provisioning and manylinux validation; skipping either manylinux step would weaken the wheel compatibility check. + +## Acceptance criteria + +- Selector tests prove hosted routing for release/manual, foreign/fork/Dependabot events, non-Windows targets, and an unset or unknown switch value. +- A trusted native Windows run builds the executable and release-shaped wheel, passes installed-wheel keyless and required live-API tests, and uploads the wheel without global Python or registry writes. +- Concurrent jobs use distinct cache/tool roots; success, failure, and cancellation exercise cleanup without deleting another job’s paths. +- Compare elapsed time and shared-pool load against hosted Windows before claiming cost or throughput improvement. Until then this note remains proposed. + +## Risks + +Private stores and copy imports trade warm-cache speed and disk space for bounded mutation. Portable Python can select a different 3.10 patch from setup-python. Downloads remain external dependencies; hard runner termination can prevent cleanup. Shared-account trust and pool availability remain operational limits, and the hosted fallback does not prove self-hosted readiness. diff --git a/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.zh.md b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.zh.md new file mode 100644 index 0000000000..67accee541 --- /dev/null +++ b/.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 作业私有的 Windows Python runtime CI + +Status: proposed + +[English](2026-09-06-python-runtime-windows-selfhosted.md) | 中文 + +## 问题 + +原生 Python runtime 矩阵消耗托管 Windows 容量,但将构建原样迁移到共享常驻运行器会修改机器安装状态并复用用户级缓存。[CI 故障切换手册](../../implemented/process/2026-07-26-ci-failover-runbook.zh.md) 继续负责现有通用通道与运行器池前置条件;本提案仅覆盖 Python runtime 构建。 + +[只读前置条件探测](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34012679056) 发现 Windows 为原生 x64,Python 3.14.7 提供 venv/ensurepip,开发人员模式已启用,但没有 Python 工具缓存。Linux 缺少两个 manylinux 步骤都依赖的 Docker。这些观测允许开展仅针对 Windows 的实验,并不证明 runtime 构建能够通过。 + +## 提案 + +仅当 `inputs.ci && !inputs.release`、仓库为规范仓库,且事件为同仓库非 fork、非 Dependabot 的 PR(Pull Request)或 master 推送时,将 [runtime 工作流](../../../../.github/workflows/build-exe-for-python-sdk.yml) 的 Windows x64 目标路由到常驻运行器池。`DSH_CI_FAILOVER_WINDOWS=selfhosted` 启用此路由;未设置或其他值使通道留在托管运行器。发布/手动构建、其他事件、Linux/macOS 目标、规划作业与 SDK wheel 包作业继续使用托管运行器。实现尚待原生 runtime 验证。 + +[原生准备探测](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34013261224/job/101432611073) 下载 Python 3.10.20,验证命令解析与包含 pip 的冒烟 venv,断言已注册的 Python 安装与开发人员模式不变,并证明作业根目录已删除。观测到目录非空的删除失败后,Windows 递归删除使用有限重试。工作流另外在 action 后置步骤前清除导出的编译缓存路径并重置临时目录变量;定向测试固定这些赋值,它们不属于引用的探测提交。定向路由测试通过,反转故障切换条件会产生三个预期失败,随后恢复条件。完整可执行文件、wheel 包与真实 API 验证仍待完成。 + +[私有准备脚本](../../../../scripts/setup-python-runtime-windows.ps1) 使用预装解释器,在临时 venv 内引导安装 uv 0.11.23,再通过 `--no-bin --no-registry` 将托管 Python 3.10 下载到唯一的作业目录。它创建包含初始工具包的工具 venv,禁止进一步下载 Python。[固定版本的 uv 源码](https://github.com/astral-sh/uv/blob/3cdf50e0924f1ace7a92ddbac98b12a958b87688/crates/uv-cli/src/lib.rs#L6672-L6713) 提供这些参数;[实现](https://github.com/astral-sh/uv/blob/3cdf50e0924f1ace7a92ddbac98b12a958b87688/crates/uv/src/commands/python/install.rs#L667-L723) 禁止创建可执行文件链接与注册表登记。CI 检查开发人员模式,不负责启用它。 + +作业独占其 pnpm 存储、pkg/npm/node-gyp/Python/Node 缓存以及临时测试目录。依赖导入使用复制,而不是指向共享存储的链接;跳过托管缓存恢复/保存步骤。始终执行的清理步骤仅删除记录的作业根目录。检出不保留凭据。这些措施隔离资源,不能防御同一 Windows 账户下运行的恶意代码。 + +## 已考虑的替代方案 + +**使用私有工具缓存冷启动 setup-python。** 不采用:具体的 Python 3.10.11 [Windows 发布安装器](https://github.com/actions/python-versions/blob/98e79473eb342d6f43487a289ca633620404742e/installers/win-setup-template.ps1#L21-L70) 会删除匹配的机器/当前用户安装记录,并为所有用户安装。私有目录无法隔离这些注册表状态。 + +**由管理员预装 Python 3.10。** 强制仅使用缓存命中路径并采用私有依赖环境时可行,但观测到的运行器池并未提供它。便携 uv 避免要求修改主机安装。 + +**同时迁移 Linux。** 推迟到管理员批准 Docker 部署并完成 manylinux 验证之后;跳过任一 manylinux 步骤都会削弱 wheel 包兼容性检查。 + +## 验收标准 + +- 选择器测试证明发布/手动、外部仓库/fork/Dependabot 事件、非 Windows 目标及未设置或未知的开关值均使用托管路由。 +- 一次可信的原生 Windows 运行构建可执行文件与发布形态 wheel 包,通过安装后 wheel 包的无密钥测试及必需的真实 API 测试,并上传 wheel 包,期间不写全局 Python 或注册表。 +- 并发作业使用不同的缓存/工具根目录;成功、失败与取消路径均执行清理且不删除其他作业的路径。 +- 在宣称成本或吞吐量改善之前,对比托管 Windows 的耗时与共享池负载。此前本说明保持 proposed 状态。 + +## 风险 + +私有存储与复制导入以热缓存速度和磁盘空间换取受限的修改范围。便携 Python 可能选择与 setup-python 不同的 3.10 补丁版本。下载仍依赖外部服务;运行器被强制终止可能阻止清理。共享账户信任与运行器池可用性仍是运维限制,托管回退也不能证明自托管运行器已就绪。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index c00241b0e0..693dd05eb4 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -150,7 +150,20 @@ jobs: build: needs: [plan, sdk-wheel] name: ${{ matrix.target }} - runs-on: ${{ matrix.runner }} + # Release and manual builds retain disposable hosted images. Only trusted CI + # may use the persistent Windows host; Linux requires an unavailable Docker daemon. + runs-on: >- + ${{ inputs.ci && !inputs.release + && github.repository == 'deepseek-harness/deepseek-harness' + && ((github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository + && !github.event.pull_request.head.repo.fork + && github.event.pull_request.user.login != 'dependabot[bot]') + || (github.event_name == 'push' && github.ref == 'refs/heads/master')) + && matrix.target == 'node24-win-x64' + && vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + && fromJSON('["self-hosted", "dsh-win-ci", "windows", "x64"]') + || matrix.runner }} timeout-minutes: 45 strategy: fail-fast: false @@ -158,13 +171,21 @@ jobs: include: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Prepare private Windows Python toolchain + id: private-windows + if: runner.os == 'Windows' && runner.environment == 'self-hosted' + shell: pwsh + run: ./scripts/setup-python-runtime-windows.ps1 - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - name: Enable Windows Developer Mode (symlink support) - if: runner.os == 'Windows' + if: runner.os == 'Windows' && runner.environment != 'self-hosted' shell: pwsh run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" @@ -175,18 +196,22 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 - cache: pnpm + cache: ${{ runner.environment != 'self-hosted' && 'pnpm' || '' }} + package-manager-cache: false - uses: actions/setup-python@v6.3.0 + if: runner.environment != 'self-hosted' with: python-version: '3.10' - name: Install Python build tooling + if: runner.environment != 'self-hosted' run: python -m pip install uv==0.11.23 # Cache pkg's target Node binary; lockfile changes roll the # exact key while the restore prefix can seed its replacement. - uses: actions/cache@v4 + if: runner.environment != 'self-hosted' with: path: ~/.pkg-cache key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -194,8 +219,16 @@ jobs: pkg-fetch-${{ matrix.target }}- - name: Install (immutable) + if: runner.environment != 'self-hosted' run: pnpm install --frozen-lockfile + - name: Install private Windows dependencies (immutable) + if: runner.os == 'Windows' && runner.environment == 'self-hosted' + shell: pwsh + run: | + pnpm install --frozen-lockfile --package-import-method=copy + if ($LASTEXITCODE -ne 0) { throw 'Private Windows dependency installation failed.' } + - name: Rebuild Linux node-pty against manylinux 2.28 if: runner.os == 'Linux' env: @@ -480,3 +513,20 @@ jobs: path: dist-python/${{ steps.runtime-posix.outputs.wheel || steps.runtime-windows.outputs.wheel }} if-no-files-found: error retention-days: 7 + + - name: Remove private Windows toolchain and test directories + if: always() && steps.private-windows.outputs.root != '' + shell: pwsh + env: + PRIVATE_ROOT: ${{ steps.private-windows.outputs.root }} + run: | + Set-Location $env:GITHUB_WORKSPACE + $env:TMP = $env:RUNNER_TEMP + $env:TEMP = $env:RUNNER_TEMP + Remove-Item Env:NODE_COMPILE_CACHE -ErrorAction SilentlyContinue + "NODE_COMPILE_CACHE=" >> $env:GITHUB_ENV + "TMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV + "TEMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV + node -e "require('node:fs').rmSync(process.env.PRIVATE_ROOT, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })" + if ($LASTEXITCODE -ne 0) { throw 'Private Windows job directory removal failed.' } + if (Test-Path -LiteralPath $env:PRIVATE_ROOT) { throw 'Private Windows job directory survived cleanup.' } diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index f1ada26c49..e60857c439 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: aa0144d7eaa66711d0f08316d4445da060918ca8 -development.zh.md: a35f6fc8de1bdbd282fd8999a1440fde0c400b34 +development.md: f1d3278deb621a73e6fafeb2fb65e6cfc14839d9 +development.zh.md: 84744739da2a448de28a0ea24e6fa8e66c29e358 diff --git a/python/development.md b/python/development.md index aa0144d7ea..f1d3278deb 100644 --- a/python/development.md +++ b/python/development.md @@ -15,6 +15,8 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64` to select platforms. Build each target on its native architecture. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. Windows emits `.exe` and `-rg.exe`; macOS also syncs the matching spawn helper required by `node-pty`. +CI-only Windows x64 builds can use the self-hosted pool when `DSH_CI_FAILOVER_WINDOWS=selfhosted`: only same-repository non-fork, non-Dependabot pull requests and pushes to `master` qualify. The job downloads Python 3.10 into a private temporary directory without registering it in Windows, isolates build caches and test environments, and removes that directory after success or failure. Release and manual builds, Linux and macOS targets, and the SDK-wheel helper retain hosted runners. See the [runner isolation proposal](../.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.md) for image prerequisites and validation limits. + ## Validate the SDK Keep the virtual environment outside `python/`, install the test group, and run the Python suite: diff --git a/python/development.zh.md b/python/development.zh.md index a35f6fc8de..84744739da 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -15,6 +15,8 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts 所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64`。每个目标都应在其原生架构上构建。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。Windows 会生成 `.exe` 与 `-rg.exe`;macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 +仅用于 CI 的 Windows x64 构建可在 `DSH_CI_FAILOVER_WINDOWS=selfhosted` 时使用自托管池:只有同仓库、非 fork、非 Dependabot 的拉取请求以及向 `master` 的推送符合条件。作业将 Python 3.10 下载到私有临时目录而不在 Windows 中注册它,隔离构建缓存与测试环境,并在成功或失败后删除该目录。发布与手动构建、Linux 与 macOS 目标,以及 SDK wheel 辅助作业仍使用托管运行器。镜像前提与验证限制见[运行器隔离提案](../.agents/notes/proposed/process/2026-09-06-python-runtime-windows-selfhosted.zh.md)。 + ## 验证 SDK 请将虚拟环境放在 `python/` 之外,安装测试组,然后运行 Python 测试套件: diff --git a/scripts/python-runtime-selfhosted.spec.ts b/scripts/python-runtime-selfhosted.spec.ts new file mode 100644 index 0000000000..a2a072a7e4 --- /dev/null +++ b/scripts/python-runtime-selfhosted.spec.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8')) as { + jobs: Record }> }> +} +const build = workflow.jobs.build! +const selector = build['runs-on'].slice(3, -2).trim() +const windows = ['self-hosted', 'dsh-win-ci', 'windows', 'x64'] + +function context() { + return { + inputs: { ci: true, release: false }, + github: { + repository: 'deepseek-harness/deepseek-harness', + event_name: 'pull_request', + ref: 'refs/pull/42/merge', + event: { pull_request: { + head: { repo: { full_name: 'deepseek-harness/deepseek-harness', fork: false } }, + user: { login: 'contributor' }, + } }, + }, + matrix: { target: 'node24-win-x64', runner: 'windows-2025' }, + vars: { DSH_CI_FAILOVER_WINDOWS: 'selfhosted' }, + fromJSON: JSON.parse, + } +} + +function route(value: ReturnType, expression = selector): unknown { + // The workflow uses only comparisons, booleans and fromJSON; execute that exact expression. + return runInNewContext(expression, value, { timeout: 1000 }) +} + +describe('Python runtime self-hosted routing', () => { + it('routes same-repository member PRs and master CI to native x64 Windows', () => { + expect(route(context())).toEqual(windows) + const master = context() + master.github.event_name = 'push' + master.github.ref = 'refs/heads/master' + expect(route(master)).toEqual(windows) + }) + + it.each([ + ['release caller', (value: ReturnType) => { value.inputs.release = true }], + ['non-CI caller', (value: ReturnType) => { value.inputs.ci = false }], + ['manual dispatch', (value: ReturnType) => { value.github.event_name = 'workflow_dispatch' }], + ['pull_request_target', (value: ReturnType) => { value.github.event_name = 'pull_request_target' }], + ['unknown event', (value: ReturnType) => { value.github.event_name = '' }], + ['fork', (value: ReturnType) => { value.github.event.pull_request.head.repo.fork = true }], + ['different repository head', (value: ReturnType) => { value.github.event.pull_request.head.repo.full_name = 'someone/fork' }], + ['different caller repository', (value: ReturnType) => { value.github.repository = 'someone/fork' }], + ['Dependabot author', (value: ReturnType) => { value.github.event.pull_request.user.login = 'dependabot[bot]' }], + ['disabled failover', (value: ReturnType) => { value.vars.DSH_CI_FAILOVER_WINDOWS = '' }], + ['unknown failover value', (value: ReturnType) => { value.vars.DSH_CI_FAILOVER_WINDOWS = 'hosted' }], + ['branch push', (value: ReturnType) => { value.github.event_name = 'push'; value.github.ref = 'refs/heads/topic' }], + ['tag push', (value: ReturnType) => { value.github.event_name = 'push'; value.github.ref = 'refs/tags/python-v1' }], + ] as const)('keeps %s on the hosted fallback', (_name, change) => { + const value = context() + change(value) + expect(route(value)).toBe('windows-2025') + }) + + it.each([ + ['node24-linux-x64', 'ubuntu-latest'], + ['node24-linux-arm64', 'ubuntu-24.04-arm'], + ['node24-macos-arm64', 'macos-latest'], + ['node24-macos-x64', 'macos-15-intel'], + ])('keeps %s hosted even with failover enabled', (target, runner) => { + const value = context() + value.matrix = { target, runner } + expect(route(value)).toBe(runner) + }) + + it('keeps setup helper jobs on hosted images', () => { + expect(workflow.jobs.plan!['runs-on']).toBe('ubuntu-latest') + expect(workflow.jobs['sdk-wheel']!['runs-on']).toBe('ubuntu-latest') + }) + + it('isolates setup before pnpm and excludes shared installers and cache archives', () => { + const privateSetup = build.steps.findIndex(step => step.id === 'private-windows') + expect(privateSetup).toBeGreaterThan(0) + expect(privateSetup).toBeLessThan(build.steps.findIndex(step => step.uses?.startsWith('pnpm/action-setup@'))) + for (const step of build.steps.filter(step => step.uses?.startsWith('actions/setup-python@') || step.uses?.startsWith('actions/cache@') || step.name === 'Install Python build tooling')) { + expect(step.if).toBe("runner.environment != 'self-hosted'") + } + expect(build.steps.find(step => step.name?.startsWith('Enable Windows'))?.if).toBe("runner.os == 'Windows' && runner.environment != 'self-hosted'") + expect(build.steps.find(step => step.uses?.startsWith('actions/setup-node@'))?.with?.cache).toContain("runner.environment != 'self-hosted'") + expect(build.steps.at(-1)).toMatchObject({ if: "always() && steps.private-windows.outputs.root != ''", shell: 'pwsh' }) + const cleanup = build.steps.at(-1)!.run! + expect(cleanup).toContain('"NODE_COMPILE_CACHE=" >> $env:GITHUB_ENV') + expect(cleanup).toContain('"TMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV') + expect(cleanup).toContain('"TEMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV') + expect(cleanup).toContain('maxRetries: 10, retryDelay: 100') + }) + + it('pins portable Python without registry or shared cache writes', () => { + const setup = readFileSync(resolve(root, 'scripts/setup-python-runtime-windows.ps1'), 'utf8') + expect(setup).toContain('--no-bin --no-registry 3.10') + expect(setup).toContain('--managed-python --no-python-downloads --seed') + expect(setup).toContain('UV_PYTHON_INSTALL_REGISTRY') + expect(setup).toContain('PNPM_CONFIG_STORE_DIR') + expect(setup).toContain('PKG_CACHE_PATH') + expect(setup).not.toMatch(/reg add|Set-ItemProperty|InstallAllUsers/) + }) +}) diff --git a/scripts/setup-python-runtime-windows.ps1 b/scripts/setup-python-runtime-windows.ps1 new file mode 100644 index 0000000000..4303721bb6 --- /dev/null +++ b/scripts/setup-python-runtime-windows.ps1 @@ -0,0 +1,53 @@ +# Prepare a job-private Python 3.10 toolchain without Windows installer or registry writes. +$ErrorActionPreference = 'Stop' +$root = Join-Path $env:RUNNER_TEMP ("python-runtime-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $root | Out-Null +"root=$root" >> $env:GITHUB_OUTPUT + +$privateEnvironment = @{ + TMP = $root + TEMP = $root + UV_CACHE_DIR = (Join-Path $root 'uv-cache') + UV_PYTHON_INSTALL_DIR = (Join-Path $root 'python') + UV_PYTHON_INSTALL_BIN = '0' + UV_PYTHON_INSTALL_REGISTRY = '0' + UV_NO_CONFIG = '1' + PIP_CACHE_DIR = (Join-Path $root 'pip-cache') + npm_config_cache = (Join-Path $root 'npm-cache') + npm_config_devdir = (Join-Path $root 'node-gyp') + PNPM_CONFIG_PACKAGE_IMPORT_METHOD = 'copy' + PKG_CACHE_PATH = (Join-Path $root 'pkg-cache') + PNPM_CONFIG_STORE_DIR = (Join-Path $root 'pnpm-store') + NODE_COMPILE_CACHE = (Join-Path $root 'node-compile-cache') +} +foreach ($entry in $privateEnvironment.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process') + "$($entry.Key)=$($entry.Value)" >> $env:GITHUB_ENV +} + +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { + throw 'Python runtime CI requires a native x64 Windows host.' +} +$devMode = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' -Name AllowDevelopmentWithoutDevLicense +if ($devMode.AllowDevelopmentWithoutDevLicense -ne 1) { + throw 'The self-hosted Windows image must enable Developer Mode before CI.' +} + +$bootstrap = Join-Path $root 'bootstrap' +python -m venv $bootstrap +if ($LASTEXITCODE -ne 0) { throw 'The self-hosted Windows image requires Python with venv and ensurepip.' } +$bootstrapScripts = Join-Path $bootstrap 'Scripts' +& (Join-Path $bootstrapScripts 'python.exe') -m pip --isolated --disable-pip-version-check --no-cache-dir install uv==0.11.23 +if ($LASTEXITCODE -ne 0) { throw 'Job-private uv installation failed.' } +$uv = Join-Path $bootstrapScripts 'uv.exe' +& $uv python install --install-dir $env:UV_PYTHON_INSTALL_DIR --no-bin --no-registry 3.10 +if ($LASTEXITCODE -ne 0) { throw 'Job-private Python 3.10 download failed.' } +$tooling = Join-Path $root 'tooling' +& $uv venv --python 3.10 --managed-python --no-python-downloads --seed $tooling +if ($LASTEXITCODE -ne 0) { throw 'Job-private Python 3.10 environment creation failed.' } +$toolingScripts = Join-Path $tooling 'Scripts' +$python = Join-Path $toolingScripts 'python.exe' +& $python -c 'import platform, sys; assert sys.version_info[:2] == (3, 10); assert platform.machine() == "AMD64"; print(sys.version); print(sys.executable)' +if ($LASTEXITCODE -ne 0) { throw 'Job-private Python version or architecture is incorrect.' } +$bootstrapScripts >> $env:GITHUB_PATH +$toolingScripts >> $env:GITHUB_PATH