diff --git a/.github/workflows/windows-packaging-publish.yml b/.github/workflows/windows-packaging-publish.yml index f491883a..b2817d5d 100644 --- a/.github/workflows/windows-packaging-publish.yml +++ b/.github/workflows/windows-packaging-publish.yml @@ -47,6 +47,7 @@ jobs: -RepoRoot "${{ github.workspace }}" ` -AppVersion $appVersion $uvExe = Join-Path $out "tools/uv/uv.exe" + $pythonExe = Join-Path $out "tools/python/python.exe" $nodeExe = Join-Path $out "tools/node/node.exe" $chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "chrome-win" } | @@ -54,6 +55,9 @@ jobs: if (-not (Test-Path $uvExe)) { throw "uv executable not found in staging: $uvExe" } + if (-not (Test-Path $pythonExe)) { + throw "python executable not found in staging: $pythonExe" + } if (-not (Test-Path $nodeExe)) { throw "node executable not found in staging: $nodeExe" } @@ -61,6 +65,7 @@ jobs: throw "chrome executable not found in staging under tools/chrome" } $uvVersion = (& $uvExe --version).Trim() + $pythonVersion = (& $pythonExe --version).Trim() $nodeVersion = (& $nodeExe --version).Trim() $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion if ([string]::IsNullOrWhiteSpace($chromeVersion)) { @@ -71,6 +76,8 @@ jobs: } Write-Host "[runtime] pinned uv version: $($manifest.uv.version)" Write-Host "[runtime] bundled uv version: $uvVersion" + Write-Host "[runtime] pinned python version: $($manifest.python.version)" + Write-Host "[runtime] bundled python version: $pythonVersion" Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)" Write-Host "[runtime] bundled node version: $nodeVersion" Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)" @@ -78,6 +85,9 @@ jobs: if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) { throw "Bundled uv version does not match pinned version in manifest" } + if ($pythonVersion -ne ("Python " + $manifest.python.version)) { + throw "Bundled python version does not match pinned version in manifest" + } if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) { throw "Bundled node version does not match pinned version in manifest" } diff --git a/.github/workflows/windows-packaging.yml b/.github/workflows/windows-packaging.yml index 1e595d9f..ea37b51c 100644 --- a/.github/workflows/windows-packaging.yml +++ b/.github/workflows/windows-packaging.yml @@ -42,6 +42,7 @@ jobs: $manifest = Get-Content -Path $manifestPath -Raw -Encoding utf8 | ConvertFrom-Json & "${{ github.workspace }}/packaging/windows/build-installer.ps1" -OutputDir $out -RepoRoot "${{ github.workspace }}" $uvExe = Join-Path $out "tools/uv/uv.exe" + $pythonExe = Join-Path $out "tools/python/python.exe" $nodeExe = Join-Path $out "tools/node/node.exe" $chromeExe = Get-ChildItem -Path (Join-Path $out "tools/chrome") -Recurse -Filter "chrome.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "chrome-win" } | @@ -49,6 +50,9 @@ jobs: if (-not (Test-Path $uvExe)) { throw "uv executable not found in staging: $uvExe" } + if (-not (Test-Path $pythonExe)) { + throw "python executable not found in staging: $pythonExe" + } if (-not (Test-Path $nodeExe)) { throw "node executable not found in staging: $nodeExe" } @@ -56,6 +60,7 @@ jobs: throw "chrome executable not found in staging under tools/chrome" } $uvVersion = (& $uvExe --version).Trim() + $pythonVersion = (& $pythonExe --version).Trim() $nodeVersion = (& $nodeExe --version).Trim() $chromeVersion = (Get-Item -LiteralPath $chromeExe.FullName).VersionInfo.ProductVersion if ([string]::IsNullOrWhiteSpace($chromeVersion)) { @@ -66,6 +71,8 @@ jobs: } Write-Host "[runtime] pinned uv version: $($manifest.uv.version)" Write-Host "[runtime] bundled uv version: $uvVersion" + Write-Host "[runtime] pinned python version: $($manifest.python.version)" + Write-Host "[runtime] bundled python version: $pythonVersion" Write-Host "[runtime] pinned node version: $($manifest.nodejs.version)" Write-Host "[runtime] bundled node version: $nodeVersion" Write-Host "[runtime] pinned chrome version: $($manifest.chrome_for_testing.version)" @@ -73,6 +80,9 @@ jobs: if ($uvVersion -notmatch ("^uv\s+" + [regex]::Escape($manifest.uv.version) + "(\s|$)")) { throw "Bundled uv version does not match pinned version in manifest" } + if ($pythonVersion -ne ("Python " + $manifest.python.version)) { + throw "Bundled python version does not match pinned version in manifest" + } if ($nodeVersion -ne ("v" + $manifest.nodejs.version)) { throw "Bundled node version does not match pinned version in manifest" } diff --git a/packaging/windows/bootstrap-windows.ps1 b/packaging/windows/bootstrap-windows.ps1 index e8ace3db..268c8907 100644 --- a/packaging/windows/bootstrap-windows.ps1 +++ b/packaging/windows/bootstrap-windows.ps1 @@ -67,6 +67,23 @@ function Add-UserPathEntryIfMissing { } } +function Add-ProcessPathEntryIfMissing { + param([string]$Entry) + + if ([string]::IsNullOrWhiteSpace($Entry)) { return } + + $processPath = $env:Path + if ([string]::IsNullOrWhiteSpace($processPath)) { + $env:Path = $Entry + return + } + + $existing = $processPath -split ';' | Where-Object { ($_.TrimEnd('\','/')).ToLower() -eq $Entry.TrimEnd('\','/').ToLower() } + if (-not $existing) { + $env:Path = "$Entry;$processPath" + } +} + function Resolve-ChromeExecutablePath { param([string]$BrowserRoot) @@ -107,6 +124,20 @@ else { Write-Host "[flocks-bootstrap] warning: bundled node not found at $bundledNode" -ForegroundColor Yellow } +$bundledPythonDir = Join-Path $InstallRoot "tools\python" +$bundledPython = Join-Path $bundledPythonDir "python.exe" +if (Test-Path $bundledPython) { + Add-ProcessPathEntryIfMissing -Entry $bundledPythonDir + $env:FLOCKS_BUNDLED_PYTHON = $bundledPython + $env:UV_PYTHON = $bundledPython + $env:UV_PYTHON_DOWNLOADS = "never" + $env:UV_NO_MANAGED_PYTHON = "1" + Write-Host "[flocks-bootstrap] configured bundled Python runtime: $bundledPython" +} +else { + Write-Host "[flocks-bootstrap] warning: bundled Python runtime not found at $bundledPython" -ForegroundColor Yellow +} + # 2) Expose bundled Chrome for Testing under ~/.flocks/browser so install.ps1's # Resolve-ChromeForTestingPath finds it and skips the real download. # Prefer a directory junction (fast, no disk duplication) and fall back to copy. diff --git a/packaging/windows/build-staging.ps1 b/packaging/windows/build-staging.ps1 index 175bb635..ddee15d4 100644 --- a/packaging/windows/build-staging.ps1 +++ b/packaging/windows/build-staging.ps1 @@ -157,11 +157,35 @@ function Get-OrDownloadFileFromCandidates { throw "Failed to download $Label" } +function Expand-TarGzArchive { + param( + [Parameter(Mandatory = $true)][string]$ArchivePath, + [Parameter(Mandatory = $true)][string]$DestinationPath + ) + + $tarExe = Get-Command tar.exe -ErrorAction SilentlyContinue + if (-not $tarExe) { + throw "tar.exe is required to extract $ArchivePath" + } + + Remove-PathWithRetry -Path $DestinationPath + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + + & $tarExe.Source -xzf $ArchivePath -C $DestinationPath + if ($LASTEXITCODE -ne 0) { + throw "tar.exe failed to extract $ArchivePath with exit code $LASTEXITCODE" + } + $global:LASTEXITCODE = 0 +} + Write-Host "[build-staging] RepoRoot: $RepoRoot" Write-Host "[build-staging] OutputDir: $OutputDir" $manifest = Read-Manifest -Path $ManifestPath $uvVersion = $manifest.uv.version +$pythonVersion = $manifest.python.version +$pythonStandaloneRelease = $manifest.python.python_build_standalone_release +$pythonArchiveName = $manifest.python.windows_archive_name $nodeVersion = $manifest.nodejs.version $nodeSuffix = $manifest.nodejs.windows_zip_suffix $cacheRoot = Resolve-CacheRoot -RepoRoot $RepoRoot -CacheRootOverride $CacheRoot @@ -170,11 +194,13 @@ Write-Host "[build-staging] CacheRoot: $cacheRoot" Ensure-EmptyDir -Path $OutputDir $toolsUv = Join-Path $OutputDir "tools\uv" +$toolsPython = Join-Path $OutputDir "tools\python" $toolsNode = Join-Path $OutputDir "tools\node" $toolsChrome = Join-Path $OutputDir "tools\chrome" $flocksDest = Join-Path $OutputDir "flocks" New-Item -ItemType Directory -Path $toolsUv -Force | Out-Null +New-Item -ItemType Directory -Path $toolsPython -Force | Out-Null New-Item -ItemType Directory -Path $toolsNode -Force | Out-Null New-Item -ItemType Directory -Path $toolsChrome -Force | Out-Null @@ -185,6 +211,38 @@ $uvZip = Join-Path $cacheRoot "downloads\uv-$uvVersion-$uvZipName" Get-OrDownloadFile -Url $uvUrl -CachePath $uvZip -Label "uv $uvVersion" Expand-Archive -Path $uvZip -DestinationPath $toolsUv -Force +# Python runtime (python-build-standalone install-only archive) +$pythonArchiveNameEscaped = [Uri]::EscapeDataString($pythonArchiveName) +$pythonMirrorBase = $env:FLOCKS_PYTHON_STANDALONE_MIRROR_BASE_URL +$pythonUrls = @() +if (-not [string]::IsNullOrWhiteSpace($pythonMirrorBase)) { + $mirrorBase = $pythonMirrorBase.TrimEnd('/') + $pythonUrls += "$mirrorBase/$pythonStandaloneRelease/$pythonArchiveNameEscaped" + Write-Host "[build-staging] Added python-build-standalone mirror candidate from FLOCKS_PYTHON_STANDALONE_MIRROR_BASE_URL" +} +$pythonUrls += "https://github.com/astral-sh/python-build-standalone/releases/download/$pythonStandaloneRelease/$pythonArchiveNameEscaped" +$pythonArchive = Join-Path $cacheRoot "downloads\python-$pythonVersion-$pythonStandaloneRelease-$pythonArchiveName" +Get-OrDownloadFileFromCandidates -Urls $pythonUrls -CachePath $pythonArchive -Label "Python $pythonVersion" + +$pythonExtract = Join-Path $env:TEMP "python-extract-$pythonVersion-$pythonStandaloneRelease" +Expand-TarGzArchive -ArchivePath $pythonArchive -DestinationPath $pythonExtract +$pythonExe = Get-ChildItem -Path $pythonExtract -Recurse -Filter "python.exe" -File -ErrorAction SilentlyContinue | + Where-Object { $_.DirectoryName -notmatch '\\DLLs($|\\)' } | + Select-Object -First 1 +if (-not $pythonExe) { + throw "python.exe not found after extracting bundled Python runtime" +} +$pythonSource = $pythonExe.Directory.FullName +robocopy $pythonSource $toolsPython /E /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "robocopy failed while copying bundled Python with exit code $LASTEXITCODE" +} +$global:LASTEXITCODE = 0 +Remove-PathWithRetry -Path $pythonExtract +if (-not (Test-Path (Join-Path $toolsPython "python.exe"))) { + throw "Bundled Python runtime missing python.exe under tools\python after extraction" +} + # Node.js official zip (portable) $nodeZipName = "node-v$nodeVersion-$nodeSuffix.zip" $nodeUrl = "https://nodejs.org/dist/v$nodeVersion/$nodeZipName" diff --git a/packaging/windows/versions.manifest.json b/packaging/windows/versions.manifest.json index 1d420759..d26eaf5c 100644 --- a/packaging/windows/versions.manifest.json +++ b/packaging/windows/versions.manifest.json @@ -3,6 +3,11 @@ "uv": { "version": "0.9.15" }, + "python": { + "version": "3.12.12", + "python_build_standalone_release": "20251202", + "windows_archive_name": "cpython-3.12.12+20251202-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" + }, "nodejs": { "version": "24.14.0", "windows_zip_suffix": "win-x64" diff --git a/tests/packaging/test_windows_manifest.py b/tests/packaging/test_windows_manifest.py index 78aea3d1..55bff6da 100644 --- a/tests/packaging/test_windows_manifest.py +++ b/tests/packaging/test_windows_manifest.py @@ -14,3 +14,14 @@ def test_windows_bundled_uv_supports_python_downloads_json_url() -> None: manifest = json.loads(WINDOWS_MANIFEST.read_text(encoding="utf-8")) assert _parse_version(manifest["uv"]["version"]) >= (0, 7, 3) + + +def test_windows_manifest_pins_bundled_python_runtime() -> None: + manifest = json.loads(WINDOWS_MANIFEST.read_text(encoding="utf-8")) + + python = manifest["python"] + assert _parse_version(python["version"]) >= (3, 12, 0) + assert python["python_build_standalone_release"].isdigit() + assert python["windows_archive_name"].endswith(".tar.gz") + assert "install_only" in python["windows_archive_name"] + assert "windows-msvc" in python["windows_archive_name"] diff --git a/tests/scripts/test_browser_runtime_configuration.py b/tests/scripts/test_browser_runtime_configuration.py index 24fe4756..13783a6b 100644 --- a/tests/scripts/test_browser_runtime_configuration.py +++ b/tests/scripts/test_browser_runtime_configuration.py @@ -68,10 +68,16 @@ def test_powershell_bootstrap_wires_bundled_toolchain() -> None: """packaging/windows/bootstrap-windows.ps1 is the single place that bridges the bundled layout to install.ps1.""" script = (PACKAGING_WINDOWS_DIR / "bootstrap-windows.ps1").read_text(encoding="utf-8-sig") + assert "Add-ProcessPathEntryIfMissing" in script assert "Resolve-ChromeExecutablePath" in script assert "FLOCKS_SKIP_ADMIN_CHECK" in script assert "FLOCKS_BROWSER_EXECUTABLE_OVERRIDE" in script + assert "FLOCKS_BUNDLED_PYTHON" in script + assert "UV_PYTHON" in script + assert "UV_PYTHON_DOWNLOADS" in script + assert "UV_NO_MANAGED_PYTHON" in script assert "tools\\uv" in script + assert "tools\\python" in script assert "tools\\node" in script assert "tools\\chrome" in script assert ".flocks\\browser" in script @@ -79,6 +85,16 @@ def test_powershell_bootstrap_wires_bundled_toolchain() -> None: assert 'scripts\\install_zh.ps1' in script +def test_build_staging_bundles_python_runtime() -> None: + script = (PACKAGING_WINDOWS_DIR / "build-staging.ps1").read_text(encoding="utf-8-sig") + + assert "tools\\python" in script + assert "python-build-standalone" in script + assert "python.exe" in script + assert "tar.exe" in script + assert "FLOCKS_PYTHON_STANDALONE_MIRROR_BASE_URL" in script + + def test_inno_setup_points_to_packaging_bootstrap() -> None: """flocks-setup.iss must invoke the bootstrap from its new packaging location.""" iss = (PACKAGING_WINDOWS_DIR / "flocks-setup.iss").read_text(encoding="utf-8")