From d9d8c753794e1f43ac7231d2207107321baf40f9 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 22 Aug 2026 11:54:39 +0800 Subject: [PATCH 1/5] ci(windows): validate caches with safe fallbacks --- .github/workflows/ci-windows.yml | 98 +++++- .github/workflows/release-preview-windows.yml | 80 ++++- .github/workflows/release-windows.yml | 84 ++++- scripts/build-windows.ps1 | 8 +- scripts/classify-ci-changes.sh | 6 +- .../install-windows-frontend-dependencies.ps1 | 61 ++++ scripts/invoke-cargo-with-cache-fallback.ps1 | 40 +++ scripts/invoke-windows-tauri-build.ps1 | 40 +++ scripts/package-windows.ps1 | 39 ++- scripts/prepare-jdtls.ps1 | 12 +- scripts/test-classify-ci-changes.sh | 4 + .../test-verify-windows-download-cache.mjs | 114 +++++++ scripts/validate-windows-build-caches.ps1 | 98 ++++++ scripts/verify-windows-download-cache.mjs | 321 ++++++++++++++++++ windows/tauri/rust-toolchain.toml | 2 +- 15 files changed, 978 insertions(+), 29 deletions(-) create mode 100644 scripts/install-windows-frontend-dependencies.ps1 create mode 100644 scripts/invoke-cargo-with-cache-fallback.ps1 create mode 100644 scripts/invoke-windows-tauri-build.ps1 create mode 100644 scripts/test-verify-windows-download-cache.mjs create mode 100644 scripts/validate-windows-build-caches.ps1 create mode 100644 scripts/verify-windows-download-cache.mjs diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index cf7653e96..2c8706be8 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -105,15 +105,91 @@ jobs: if: needs.changes.outputs.windows == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.12" + bun-version: "1.3.14" - - name: Restore Cargo dependencies + - name: Configure isolated dependency caches + shell: pwsh + run: | + "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + + - name: Resolve Rust cache identity + id: rust-cache + shell: pwsh + run: | + $commit = rustc -Vv | + Select-String '^commit-hash:' | + ForEach-Object { ($_.Line -split ':', 2)[1].Trim() } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($commit)) { + throw "Could not resolve the Rust compiler commit hash." + } + "commit=$commit" >> $env:GITHUB_OUTPUT + + - name: Restore Cargo downloads + id: cargo-download-cache + continue-on-error: true uses: actions/cache@v6 with: path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock', 'windows/tauri/src-tauri/Cargo.lock') }} + .artifacts/cargo-home/registry/cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1-${{ hashFiles('rust/Cargo.lock', 'windows/tauri/src-tauri/Cargo.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1- + + - name: Restore Cargo build outputs + id: cargo-build-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: | + windows/tauri/src-tauri/target/**/.fingerprint + windows/tauri/src-tauri/target/**/build + windows/tauri/src-tauri/target/**/deps + windows/tauri/src-tauri/target/**/incremental + rust/target/**/.fingerprint + rust/target/**/build + rust/target/**/deps + rust/target/**/incremental + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}-${{ hashFiles('rust/Cargo.lock', 'rust/**/Cargo.toml', 'windows/tauri/src-tauri/Cargo.lock', 'windows/tauri/src-tauri/Cargo.toml', 'windows/tauri/crates/**/Cargo.toml', 'windows/tauri/rust-toolchain.toml') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}- + + - name: Restore Bun downloads + if: needs.changes.outputs.windows == 'true' + id: bun-download-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/bun-cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + + - name: Restore JDTLS downloads + if: needs.changes.outputs.windows == 'true' + id: jdtls-download-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/jdtls-downloads + key: lithe-${{ runner.os }}-${{ runner.arch }}-jdtls-v1-${{ hashFiles('third_party/jdtls/manifest.json') }} + + - name: Validate restored caches or fall back + if: always() + shell: pwsh + run: | + $validation = @{ + CargoDownloadsOutcome = "${{ steps.cargo-download-cache.outcome }}" + CargoBuildOutcome = "${{ steps.cargo-build-cache.outcome }}" + CargoBuildHit = "${{ steps.cargo-build-cache.outputs.cache-hit }}" + BunOutcome = "${{ steps.bun-download-cache.outcome }}" + JdtlsOutcome = "${{ steps.jdtls-download-cache.outcome }}" + } + if ("${{ needs.changes.outputs.windows }}" -eq "true") { + $validation.IncludeWindowsAssets = $true + } + ./scripts/validate-windows-build-caches.ps1 @validation - name: Build Windows Tauri application if: needs.changes.outputs.windows == 'true' @@ -128,12 +204,20 @@ jobs: - name: Test shared Rust Core if: needs.changes.outputs.rust_core == 'true' shell: pwsh - run: cargo test --manifest-path rust/Cargo.toml -p lithe-core + run: | + ./scripts/invoke-cargo-with-cache-fallback.ps1 ` + -TargetDirectory "rust/target" ` + -FailureMessage "Shared Rust Core tests failed" ` + -CargoArguments @("test", "--manifest-path", "rust/Cargo.toml", "-p", "lithe-core") - name: Test Windows Rust host if: needs.changes.outputs.windows_rust == 'true' shell: pwsh - run: cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml + run: | + ./scripts/invoke-cargo-with-cache-fallback.ps1 ` + -TargetDirectory "windows/tauri/src-tauri/target" ` + -FailureMessage "Windows Rust host tests failed" ` + -CargoArguments @("test", "--manifest-path", "windows/tauri/src-tauri/Cargo.toml") gate: name: Windows CI gate diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml index 5b3b52741..a64928760 100644 --- a/.github/workflows/release-preview-windows.yml +++ b/.github/workflows/release-preview-windows.yml @@ -54,11 +54,85 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.12" + bun-version: "1.3.14" - - name: Build Windows Tauri application + - name: Configure isolated dependency caches shell: pwsh - run: ./scripts/build-windows.ps1 -Configuration Release + run: | + "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + + - name: Resolve Rust cache identity + id: rust-cache + shell: pwsh + run: | + $commit = rustc -Vv | + Select-String '^commit-hash:' | + ForEach-Object { ($_.Line -split ':', 2)[1].Trim() } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($commit)) { + throw "Could not resolve the Rust compiler commit hash." + } + "commit=$commit" >> $env:GITHUB_OUTPUT + + - name: Restore Cargo downloads + id: cargo-download-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: | + .artifacts/cargo-home/registry/cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1-${{ hashFiles('rust/Cargo.lock', 'windows/tauri/src-tauri/Cargo.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1- + + - name: Restore Cargo build outputs + id: cargo-build-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: | + windows/tauri/src-tauri/target/**/.fingerprint + windows/tauri/src-tauri/target/**/build + windows/tauri/src-tauri/target/**/deps + windows/tauri/src-tauri/target/**/incremental + rust/target/**/.fingerprint + rust/target/**/build + rust/target/**/deps + rust/target/**/incremental + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}-${{ hashFiles('rust/Cargo.lock', 'rust/**/Cargo.toml', 'windows/tauri/src-tauri/Cargo.lock', 'windows/tauri/src-tauri/Cargo.toml', 'windows/tauri/crates/**/Cargo.toml', 'windows/tauri/rust-toolchain.toml') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}- + + - name: Restore Bun downloads + id: bun-download-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/bun-cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + + - name: Restore JDTLS downloads + id: jdtls-download-cache + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/jdtls-downloads + key: lithe-${{ runner.os }}-${{ runner.arch }}-jdtls-v1-${{ hashFiles('third_party/jdtls/manifest.json') }} + + - name: Validate restored caches or fall back + if: always() + shell: pwsh + run: | + ./scripts/validate-windows-build-caches.ps1 ` + -CargoDownloadsOutcome "${{ steps.cargo-download-cache.outcome }}" ` + -CargoBuildOutcome "${{ steps.cargo-build-cache.outcome }}" ` + -CargoBuildHit "${{ steps.cargo-build-cache.outputs.cache-hit }}" ` + -BunOutcome "${{ steps.bun-download-cache.outcome }}" ` + -JdtlsOutcome "${{ steps.jdtls-download-cache.outcome }}" ` + -IncludeWindowsAssets - name: Import Authenticode certificate id: signing diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 2189eefbc..e8e3cd5fe 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -38,7 +38,85 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.12" + bun-version: "1.3.14" + + - name: Configure isolated dependency caches + shell: pwsh + run: | + "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + + - name: Resolve Rust cache identity + id: rust-cache + shell: pwsh + run: | + $commit = rustc -Vv | + Select-String '^commit-hash:' | + ForEach-Object { ($_.Line -split ':', 2)[1].Trim() } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($commit)) { + throw "Could not resolve the Rust compiler commit hash." + } + "commit=$commit" >> $env:GITHUB_OUTPUT + + - name: Restore Cargo downloads + id: cargo-download-cache + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: | + .artifacts/cargo-home/registry/cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1-${{ hashFiles('rust/Cargo.lock', 'windows/tauri/src-tauri/Cargo.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-downloads-v1- + + - name: Restore Cargo build outputs + id: cargo-build-cache + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: | + windows/tauri/src-tauri/target/**/.fingerprint + windows/tauri/src-tauri/target/**/build + windows/tauri/src-tauri/target/**/deps + windows/tauri/src-tauri/target/**/incremental + rust/target/**/.fingerprint + rust/target/**/build + rust/target/**/deps + rust/target/**/incremental + key: lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}-${{ hashFiles('rust/Cargo.lock', 'rust/**/Cargo.toml', 'windows/tauri/src-tauri/Cargo.lock', 'windows/tauri/src-tauri/Cargo.toml', 'windows/tauri/crates/**/Cargo.toml', 'windows/tauri/rust-toolchain.toml') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-cargo-build-v1-x86_64-pc-windows-msvc-${{ steps.rust-cache.outputs.commit }}- + + - name: Restore Bun downloads + id: bun-download-cache + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: .artifacts/bun-cache + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + restore-keys: | + lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + + - name: Restore JDTLS downloads + id: jdtls-download-cache + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: .artifacts/jdtls-downloads + key: lithe-${{ runner.os }}-${{ runner.arch }}-jdtls-v1-${{ hashFiles('third_party/jdtls/manifest.json') }} + + - name: Validate restored caches or fall back + if: always() + shell: pwsh + run: | + ./scripts/validate-windows-build-caches.ps1 ` + -CargoDownloadsOutcome "${{ steps.cargo-download-cache.outcome }}" ` + -CargoBuildOutcome "${{ steps.cargo-build-cache.outcome }}" ` + -CargoBuildHit "${{ steps.cargo-build-cache.outputs.cache-hit }}" ` + -BunOutcome "${{ steps.bun-download-cache.outcome }}" ` + -JdtlsOutcome "${{ steps.jdtls-download-cache.outcome }}" ` + -IncludeWindowsAssets - name: Resolve release version id: version @@ -58,10 +136,6 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT "tag=v$version" >> $env:GITHUB_OUTPUT - - name: Build Windows Tauri application - shell: pwsh - run: ./scripts/build-windows.ps1 -Configuration Release - - name: Import Authenticode certificate id: signing shell: pwsh diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 72ab82883..de1b0e9e1 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -29,8 +29,7 @@ if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { & rustup target add $RustTarget if ($LASTEXITCODE -ne 0) { throw "Could not install Rust target $RustTarget" } -& bun install --frozen-lockfile -if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed" } +& (Join-Path $root "scripts/install-windows-frontend-dependencies.ps1") & bun run typecheck if ($LASTEXITCODE -ne 0) { throw "Windows frontend type check failed" } @@ -46,8 +45,9 @@ if ($Configuration -eq "Debug") { } else { $tauriArgs += @("--config", "src-tauri/tauri.jdtls.conf.json") } -& bunx @tauriArgs -if ($LASTEXITCODE -ne 0) { throw "Windows Tauri build failed" } +& (Join-Path $root "scripts/invoke-windows-tauri-build.ps1") ` + -TauriArguments $tauriArgs ` + -FailureMessage "Windows Tauri build failed" $profileName = if ($Configuration -eq "Debug") { "debug" } else { "release" } $cargoTargetRoot = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "src-tauri/target")) diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index bb2aa4913..672f47f80 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -289,7 +289,11 @@ while IFS=$'\t' read -r status first_path _; do scripts/build-macos.sh|scripts/verify-macos-app-build-safety.sh|scripts/verify-macos-package.sh|scripts/macos13sdkcompatibility.h|scripts/ld-macos13-compat.sh|scripts/package-app.sh|scripts/preview.sh|scripts/stamp-macos-app-build-info.sh|scripts/create-dmg.sh|scripts/create-macos-update-manifest.rb|scripts/test-macos-update-manifest.rb|scripts/prepare-jdtls.sh) macos_release=true ;; - scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/package-windows.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) + scripts/verify-windows-download-cache.mjs|scripts/test-verify-windows-download-cache.mjs|scripts/validate-windows-build-caches.ps1|scripts/invoke-cargo-with-cache-fallback.ps1) + windows=true + windows_rust=true + ;; + scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) windows=true ;; scripts/prepare-lithe-pr-review.mjs|scripts/test-prepare-lithe-pr-review.mjs|scripts/run-lithe-codex-with-timeout.sh|scripts/update-repo-charts.py) diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 new file mode 100644 index 000000000..eb3dcf047 --- /dev/null +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$windowsApp = Join-Path $root "windows/tauri" +$package = Get-Content -Raw -LiteralPath (Join-Path $windowsApp "package.json") | ConvertFrom-Json +$expectedVersion = ([string]$package.packageManager) -replace '^bun@', '' +$bunCache = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-cache")) +$nodeModules = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "node_modules")) +$env:BUN_INSTALL_CACHE_DIR = $bunCache + +function Write-CacheWarning { + param([string]$Message) + + Write-Warning $Message + if ($env:GITHUB_ACTIONS -eq "true") { + $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") + Write-Output "::warning title=Bun cache fallback::$escaped" + } +} + +if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { + throw "Bun is required to install Windows frontend dependencies." +} +$actualVersion = [string](& bun --version | Select-Object -Last 1) +$actualVersion = $actualVersion.Trim() +if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $expectedVersion) { + throw "Bun $expectedVersion is required, but $actualVersion is active." +} + +New-Item -ItemType Directory -Force -Path $bunCache | Out-Null +Push-Location $windowsApp +try { + & bun install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { + Write-CacheWarning "The cached Bun install failed. Clearing repository-scoped cache data and retrying with ordinary downloads." + if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + if (Test-Path -LiteralPath $nodeModules) { Remove-Item -Recurse -Force -LiteralPath $nodeModules } + New-Item -ItemType Directory -Force -Path $bunCache | Out-Null + & bun install --frozen-lockfile --no-cache + if ($LASTEXITCODE -ne 0) { + throw "Windows frontend dependency installation failed after a clean retry." + } + } + + if ($env:LITHE_BUN_CACHE_VERIFIED -ne "true" -or + -not (Test-Path -LiteralPath (Join-Path $bunCache ".lithe-integrity.json") -PathType Leaf)) { + & node (Join-Path $root "scripts/verify-windows-download-cache.mjs") ` + --cargo-cache (Join-Path $root ".artifacts/cargo-home/registry/cache") ` + --cargo-lock (Join-Path $root "rust/Cargo.lock") ` + --cargo-lock (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") ` + --bun-version $expectedVersion ` + --bun-lock (Join-Path $windowsApp "bun.lock") ` + --bun-cache $bunCache ` + --write-bun-manifest + if ($LASTEXITCODE -ne 0) { throw "Could not seal the Bun download cache." } + } +} finally { + Pop-Location +} diff --git a/scripts/invoke-cargo-with-cache-fallback.ps1 b/scripts/invoke-cargo-with-cache-fallback.ps1 new file mode 100644 index 000000000..8812722ef --- /dev/null +++ b/scripts/invoke-cargo-with-cache-fallback.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$CargoArguments, + [Parameter(Mandatory)] + [string]$TargetDirectory, + [string]$FailureMessage = "Cargo command failed" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$target = [System.IO.Path]::GetFullPath((Join-Path $root $TargetDirectory)) +$trimCharacters = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar +) +$repositoryPrefix = [System.IO.Path]::GetFullPath($root).TrimEnd($trimCharacters) + + [System.IO.Path]::DirectorySeparatorChar +if (-not $target.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Cargo target directory must stay inside the repository: $target" +} + +function Write-CacheWarning { + param([string]$Message) + + Write-Warning $Message + if ($env:GITHUB_ACTIONS -eq "true") { + $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") + Write-Output "::warning title=Cargo build cache fallback::$escaped" + } +} + +& cargo @CargoArguments +if ($LASTEXITCODE -eq 0) { exit 0 } +if ($env:LITHE_CARGO_BUILD_CACHE_RESTORED -ne "true") { throw $FailureMessage } + +Write-CacheWarning "Cargo failed after restoring build outputs. Clearing $TargetDirectory and retrying once without cached outputs." +if (Test-Path -LiteralPath $target) { Remove-Item -Recurse -Force -LiteralPath $target } +& cargo @CargoArguments +if ($LASTEXITCODE -ne 0) { throw "$FailureMessage after a clean retry" } diff --git a/scripts/invoke-windows-tauri-build.ps1 b/scripts/invoke-windows-tauri-build.ps1 new file mode 100644 index 000000000..705fc420f --- /dev/null +++ b/scripts/invoke-windows-tauri-build.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$TauriArguments, + [string]$FailureMessage = "Windows Tauri build failed" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$windowsApp = Join-Path $root "windows/tauri" +$targetRoot = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "src-tauri/target")) + +function Write-CacheWarning { + param([string]$Message) + + Write-Warning $Message + if ($env:GITHUB_ACTIONS -eq "true") { + $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") + Write-Output "::warning title=Cargo build cache fallback::$escaped" + } +} + +Push-Location $windowsApp +try { + & bunx @TauriArguments + if ($LASTEXITCODE -eq 0) { return } + + if ($env:LITHE_CARGO_BUILD_CACHE_RESTORED -ne "true") { + throw $FailureMessage + } + + Write-CacheWarning "The Tauri build failed after restoring Cargo outputs. Clearing the repository target directory and retrying once without cached outputs." + if (Test-Path -LiteralPath $targetRoot) { + Remove-Item -Recurse -Force -LiteralPath $targetRoot + } + & bunx @TauriArguments + if ($LASTEXITCODE -ne 0) { throw "$FailureMessage after a clean retry" } +} finally { + Pop-Location +} diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 84df7db15..0b1c67c68 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -2,6 +2,7 @@ param( [ValidateSet("Debug", "Release")] [string]$Configuration = "Release", + [string]$RustTarget = "x86_64-pc-windows-msvc", [string]$Version = "0.0.0", [string]$OutputDirectory = "dist", [string]$CertificateThumbprint = $env:LITHE_WINDOWS_CERTIFICATE_THUMBPRINT, @@ -15,6 +16,7 @@ param( $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $windowsApp = Join-Path $root "windows/tauri" +$bundledExtensionsSource = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "src/extensions/bundled")) $output = Join-Path $root $OutputDirectory $taskTempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { [System.IO.Path]::GetTempPath() @@ -23,6 +25,15 @@ $taskTempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { } $versionConfig = Join-Path $taskTempRoot "lithe-tauri-version.json" +if (-not (Test-Path -LiteralPath $bundledExtensionsSource -PathType Container)) { + throw "Bundled extensions source directory is missing: $bundledExtensionsSource" +} +foreach ($relativePath in @("icon-themes", "themes", "icon-themes/idea/extension.json")) { + if (-not (Test-Path -LiteralPath (Join-Path $bundledExtensionsSource $relativePath))) { + throw "Bundled extensions source is incomplete: $relativePath" + } +} + & (Join-Path $root "scripts/prepare-jdtls.ps1") | Out-Null $versionOverrides = @{ @@ -64,24 +75,38 @@ if ($RequireUpdaterArtifacts) { $versionOverrides | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $versionConfig Set-Location $windowsApp -& bun install --frozen-lockfile -if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed" } +& rustup target add $RustTarget +if ($LASTEXITCODE -ne 0) { throw "Could not install Rust target $RustTarget" } + +& (Join-Path $root "scripts/install-windows-frontend-dependencies.ps1") + +& bun run typecheck +if ($LASTEXITCODE -ne 0) { throw "Windows frontend type check failed" } $tauriArgs = @( "tauri", "build", "--config", "src-tauri/tauri.windows.conf.json", "--config", "src-tauri/tauri.jdtls.conf.json", "--config", $versionConfig, + "--target", $RustTarget, "--bundles", "nsis" ) if ($Configuration -eq "Debug") { $tauriArgs += "--debug" } -& bunx @tauriArgs -if ($LASTEXITCODE -ne 0) { throw "Tauri NSIS packaging failed" } +& (Join-Path $root "scripts/invoke-windows-tauri-build.ps1") ` + -TauriArguments $tauriArgs ` + -FailureMessage "Tauri NSIS packaging failed" -$bundleDirectory = Join-Path $windowsApp "src-tauri/target/release/bundle/nsis" -if ($Configuration -eq "Debug") { - $bundleDirectory = Join-Path $windowsApp "src-tauri/target/debug/bundle/nsis" +$profileName = if ($Configuration -eq "Debug") { "debug" } else { "release" } +$cargoTargetRoot = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "src-tauri/target")) +$profileRoot = [System.IO.Path]::GetFullPath( + (Join-Path (Join-Path $cargoTargetRoot $RustTarget) $profileName) +) +$trimCharacters = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +$targetPrefix = $cargoTargetRoot.TrimEnd($trimCharacters) + [System.IO.Path]::DirectorySeparatorChar +if (-not $profileRoot.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Windows Cargo target profile must stay inside $cargoTargetRoot" } +$bundleDirectory = Join-Path $profileRoot "bundle/nsis" $bundle = Get-ChildItem -LiteralPath $bundleDirectory -Filter "*.exe" -File | Select-Object -First 1 if ($null -eq $bundle) { throw "Tauri NSIS installer was not found in $bundleDirectory" } diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index 00ed988ca..b82ff950e 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -44,6 +44,16 @@ function Get-FileSHA256 { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } +function Write-CacheWarning { + param([Parameter(Mandatory)][string]$Message) + + Write-Warning $Message + if ($env:GITHUB_ACTIONS -eq "true") { + $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") + Write-Output "::warning title=JDTLS cache fallback::$escaped" + } +} + function Get-VerifiedDownload { param( [Parameter(Mandatory)][string]$Uri, @@ -55,7 +65,7 @@ function Get-VerifiedDownload { if (Test-Path -LiteralPath $Destination -PathType Leaf) { $actualHash = Get-FileSHA256 -Path $Destination if ($actualHash -eq $ExpectedSHA256) { return } - Write-Warning "$Description cache checksum mismatch; removing it before retrying the download" + Write-CacheWarning "$Description cache checksum mismatch; removing it before retrying the download" Remove-Item -Force -LiteralPath $Destination } diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 5adf5b028..d375f6a2c 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -130,6 +130,7 @@ modify_mixed_core_logic_test() { printf '%s\n' 'struct UpdatedLitheCoreLogicTest modify_shared_fixture() { printf '%s\n' '{"operation":"updated"}' > shared/fixtures/core/test.json; } modify_windows_frontend() { printf '%s\n' 'export const value = 2;' > windows/tauri/src/value.ts; } modify_windows_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > windows/tauri/src-tauri/src/main.rs; } +modify_windows_cache_validator() { printf '%s\n' 'console.log("updated");' > scripts/verify-windows-download-cache.mjs; } modify_metadata() { printf '%s\n' 'cask "lithe" do' ' version "1.0.0"' 'end' > Casks/lithe.rb; } modify_classifier() { printf '%s\n' '# classifier test change' >> scripts/classify-ci-changes.sh; } modify_swift_and_database() { @@ -219,6 +220,9 @@ assert_classification windows-frontend \ assert_classification windows-rust \ "$(classification false false false false false false true true false false)" \ modify_windows_rust +assert_classification windows-cache-validator \ + "$(classification false false false false false false true true false false)" \ + modify_windows_cache_validator assert_classification metadata \ "$(classification false false false false false false false false false true)" \ modify_metadata diff --git a/scripts/test-verify-windows-download-cache.mjs b/scripts/test-verify-windows-download-cache.mjs new file mode 100644 index 000000000..121b7aba1 --- /dev/null +++ b/scripts/test-verify-windows-download-cache.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const verifier = path.join(scriptDirectory, "verify-windows-download-cache.mjs"); +const emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lithe-cache-verifier-")); + +function verify(argumentsList, environment = process.env) { + return spawnSync(process.execPath, [verifier, ...argumentsList], { encoding: "utf8", env: environment }); +} + +try { + const cargoCache = path.join(testRoot, "cargo-cache", "registry"); + const cargoLock = path.join(testRoot, "Cargo.lock"); + const crate = path.join(cargoCache, "fixture-1.0.0.crate"); + await fs.mkdir(cargoCache, { recursive: true }); + await fs.writeFile( + cargoLock, + `version = 4\n\n[[package]]\nname = "fixture"\nversion = "1.0.0"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "${emptySha256}"\n`, + ); + await fs.writeFile(crate, ""); + + let result = verify(["--cargo-cache", cargoCache, "--cargo-lock", cargoLock]); + assert.equal(result.status, 0, result.stderr); + assert.equal(await fs.readFile(crate, "utf8"), ""); + + await fs.writeFile(crate, "corrupted"); + result = verify(["--cargo-cache", cargoCache, "--cargo-lock", cargoLock]); + assert.equal(result.status, 0, result.stderr); + await assert.rejects(fs.access(crate)); + assert.match(result.stderr, /SHA-256 mismatch/); + + const jdtlsCache = path.join(testRoot, "jdtls-cache"); + const jdtlsManifest = path.join(testRoot, "manifest.json"); + const jdtlsArchive = path.join(jdtlsCache, `jdtls-1.0.0-${emptySha256}.tar.gz`); + const unexpected = path.join(jdtlsCache, "unexpected.download"); + await fs.mkdir(jdtlsCache, { recursive: true }); + await fs.writeFile( + jdtlsManifest, + JSON.stringify({ + version: "1.0.0", + archiveSHA256: emptySha256, + licenseSHA256: emptySha256, + lombokVersion: "1.0.0", + lombokSHA256: emptySha256, + lombokLicenseSHA256: emptySha256, + }), + ); + await fs.writeFile(jdtlsArchive, ""); + await fs.writeFile(unexpected, "unexpected"); + + result = verify([ + "--cargo-cache", + cargoCache, + "--cargo-lock", + cargoLock, + "--jdtls-cache", + jdtlsCache, + "--jdtls-manifest", + jdtlsManifest, + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(await fs.readFile(jdtlsArchive, "utf8"), ""); + await assert.rejects(fs.access(unexpected)); + assert.match(result.stderr, /not referenced by the JDTLS manifest/); + + const fakeBin = path.join(testRoot, "bin"); + const fakeBun = path.join(fakeBin, "bun"); + const bunCache = path.join(testRoot, "bun-cache"); + const bunLock = path.join(testRoot, "bun.lock"); + const cachedPackage = path.join(bunCache, "fixture@1.0.0", "index.js"); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.writeFile(fakeBun, "#!/bin/sh\nprintf '1.3.14\\n'\n", { mode: 0o700 }); + await fs.mkdir(path.dirname(cachedPackage), { recursive: true }); + await fs.writeFile(bunLock, "fixture-lock\n"); + await fs.writeFile(cachedPackage, "export default 1;\n"); + const bunEnvironment = { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}` }; + const bunArguments = [ + "--cargo-cache", + cargoCache, + "--cargo-lock", + cargoLock, + "--bun-version", + "1.3.14", + "--bun-lock", + bunLock, + "--bun-cache", + bunCache, + ]; + + result = verify([...bunArguments, "--write-bun-manifest"], bunEnvironment); + assert.equal(result.status, 0, result.stderr); + await fs.access(path.join(bunCache, ".lithe-integrity.json")); + result = verify(bunArguments, bunEnvironment); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Bun download cache verified: 1 file/); + + await fs.writeFile(cachedPackage, "tampered\n"); + result = verify(bunArguments, bunEnvironment); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /SHA-256 mismatch/); + await assert.rejects(fs.access(cachedPackage)); + + process.stdout.write("Windows download cache verifier tests passed.\n"); +} finally { + await fs.rm(testRoot, { force: true, recursive: true }); +} diff --git a/scripts/validate-windows-build-caches.ps1 b/scripts/validate-windows-build-caches.ps1 new file mode 100644 index 000000000..5e0c74280 --- /dev/null +++ b/scripts/validate-windows-build-caches.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [string]$CargoDownloadsOutcome = "skipped", + [string]$CargoBuildOutcome = "skipped", + [string]$CargoBuildHit = "", + [string]$BunOutcome = "skipped", + [string]$JdtlsOutcome = "skipped", + [switch]$IncludeWindowsAssets +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts")) +$cargoHome = Join-Path $artifactsRoot "cargo-home" +$bunCache = Join-Path $artifactsRoot "bun-cache" +$jdtlsCache = Join-Path $artifactsRoot "jdtls-downloads" + +function Write-CacheWarning { + param([string]$Title, [string]$Message) + + Write-Warning "$Title`: $Message" + if ($env:GITHUB_ACTIONS -eq "true") { + $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") + Write-Output "::warning title=$Title::$escaped" + } +} + +function Reset-GeneratedPath { + param([string]$Path) + + $resolved = [System.IO.Path]::GetFullPath($Path) + $trimCharacters = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + $repositoryPrefix = [System.IO.Path]::GetFullPath($root).TrimEnd($trimCharacters) + + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clear cache path outside the repository: $resolved" + } + if (Test-Path -LiteralPath $resolved) { + Remove-Item -Recurse -Force -LiteralPath $resolved + } +} + +if ($CargoDownloadsOutcome -eq "failure") { + Write-CacheWarning "Cargo cache restore failed" "Discarding the partial download cache and falling back to Cargo downloads." + Reset-GeneratedPath $cargoHome +} +if ($CargoBuildOutcome -eq "failure") { + Write-CacheWarning "Cargo build cache restore failed" "Discarding partial target directories and rebuilding normally." + Reset-GeneratedPath (Join-Path $root "windows/tauri/src-tauri/target") + Reset-GeneratedPath (Join-Path $root "rust/target") +} +if ($BunOutcome -eq "failure") { + Write-CacheWarning "Bun cache restore failed" "Discarding the partial Bun cache and downloading dependencies normally." + Reset-GeneratedPath $bunCache +} +if ($JdtlsOutcome -eq "failure") { + Write-CacheWarning "JDTLS cache restore failed" "Discarding the partial JDTLS cache and downloading verified artifacts normally." + Reset-GeneratedPath $jdtlsCache +} + +$validatorArguments = @( + (Join-Path $root "scripts/verify-windows-download-cache.mjs"), + "--cargo-cache", (Join-Path $cargoHome "registry/cache"), + "--cargo-lock", (Join-Path $root "rust/Cargo.lock"), + "--cargo-lock", (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") +) +if ($IncludeWindowsAssets) { + $validatorArguments += @( + "--jdtls-cache", $jdtlsCache, + "--jdtls-manifest", (Join-Path $root "third_party/jdtls/manifest.json"), + "--bun-version", "1.3.14", + "--bun-lock", (Join-Path $root "windows/tauri/bun.lock"), + "--bun-cache", $bunCache + ) +} + +& node @validatorArguments +if ($LASTEXITCODE -ne 0) { + Write-CacheWarning "Cache validation failed" "The validator could not trust the restored downloads; all download caches will be rebuilt normally." + Reset-GeneratedPath $cargoHome + if ($IncludeWindowsAssets) { + Reset-GeneratedPath $bunCache + Reset-GeneratedPath $jdtlsCache + } +} + +$buildCacheRestored = $CargoBuildOutcome -eq "success" -and + ($CargoBuildHit -eq "true" -or $CargoBuildHit -eq "false") +if ($null -ne $env:GITHUB_ENV) { + "LITHE_CARGO_BUILD_CACHE_RESTORED=$($buildCacheRestored.ToString().ToLowerInvariant())" >> $env:GITHUB_ENV + $bunManifest = Join-Path $bunCache ".lithe-integrity.json" + $bunCacheVerified = $IncludeWindowsAssets -and (Test-Path -LiteralPath $bunManifest -PathType Leaf) + "LITHE_BUN_CACHE_VERIFIED=$($bunCacheVerified.ToString().ToLowerInvariant())" >> $env:GITHUB_ENV +} +exit 0 diff --git a/scripts/verify-windows-download-cache.mjs b/scripts/verify-windows-download-cache.mjs new file mode 100644 index 000000000..2e66ac794 --- /dev/null +++ b/scripts/verify-windows-download-cache.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { createReadStream, promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, ".."); + +function workflowEscape(value) { + return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); +} + +function warn(title, message) { + if (process.env.GITHUB_ACTIONS === "true") { + process.stdout.write(`::warning title=${workflowEscape(title)}::${workflowEscape(message)}\n`); + } else { + process.stderr.write(`warning: ${title}: ${message}\n`); + } +} + +function parseArguments(argv) { + const options = { + cargoCache: path.join(REPOSITORY_ROOT, ".artifacts", "cargo-home", "registry", "cache"), + cargoLocks: [], + jdtlsCache: null, + jdtlsManifest: null, + bunVersion: null, + bunLock: null, + bunCache: null, + writeBunManifest: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + if (option === "--write-bun-manifest") { + options.writeBunManifest = true; + continue; + } + const value = argv[index + 1]; + if (!value) throw new Error(`${option} requires a value`); + index += 1; + if (option === "--cargo-cache") options.cargoCache = value; + else if (option === "--cargo-lock") options.cargoLocks.push(value); + else if (option === "--jdtls-cache") options.jdtlsCache = value; + else if (option === "--jdtls-manifest") options.jdtlsManifest = value; + else if (option === "--bun-version") options.bunVersion = value; + else if (option === "--bun-lock") options.bunLock = value; + else if (option === "--bun-cache") options.bunCache = value; + else throw new Error(`Unknown option: ${option}`); + } + + if (options.cargoLocks.length === 0) { + options.cargoLocks.push( + path.join(REPOSITORY_ROOT, "rust", "Cargo.lock"), + path.join(REPOSITORY_ROOT, "windows", "tauri", "src-tauri", "Cargo.lock"), + ); + } + return options; +} + +async function exists(candidate) { + try { + await fs.lstat(candidate); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +async function sha256(filePath) { + return await new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +function parseTomlString(packageBlock, field) { + const match = packageBlock.match(new RegExp(`^${field} = ("(?:\\\\.|[^"\\\\])*")$`, "m")); + return match ? JSON.parse(match[1]) : null; +} + +async function expectedCargoArchives(lockPaths) { + const expected = new Map(); + for (const lockPath of lockPaths) { + const lock = await fs.readFile(lockPath, "utf8"); + for (const packageBlock of lock.split(/(?=^\[\[package\]\]$)/m)) { + if (!packageBlock.startsWith("[[package]]")) continue; + const name = parseTomlString(packageBlock, "name"); + const version = parseTomlString(packageBlock, "version"); + const checksum = parseTomlString(packageBlock, "checksum"); + if (!name || !version || !checksum) continue; + const archiveName = `${name}-${version}.crate`; + const existing = expected.get(archiveName); + if (existing && existing !== checksum) { + throw new Error(`Cargo locks disagree about the checksum for ${archiveName}`); + } + expected.set(archiveName, checksum.toLowerCase()); + } + } + return expected; +} + +async function collectFiles(root) { + if (!(await exists(root))) return []; + const rootStatus = await fs.lstat(root); + if (rootStatus.isSymbolicLink() || !rootStatus.isDirectory()) { + warn("Unsafe cache root removed", `Expected a real directory but found an unsafe entry: ${root}`); + await fs.rm(root, { force: true, recursive: true }); + await fs.mkdir(root, { recursive: true }); + return []; + } + const files = []; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + warn("Unsafe cache entry removed", `Removed symbolic link from download cache: ${candidate}`); + await fs.rm(candidate, { force: true, recursive: true }); + } else if (entry.isDirectory()) { + pending.push(candidate); + } else if (entry.isFile()) { + files.push(candidate); + } else { + warn("Unsafe cache entry removed", `Removed unsupported filesystem entry: ${candidate}`); + await fs.rm(candidate, { force: true, recursive: true }); + } + } + } + return files; +} + +async function verifyCargoCache(cacheRoot, lockPaths) { + const expected = await expectedCargoArchives(lockPaths); + let verified = 0; + let removed = 0; + for (const archive of await collectFiles(cacheRoot)) { + const archiveName = path.basename(archive); + const expectedHash = expected.get(archiveName); + const actualHash = archiveName.endsWith(".crate") ? await sha256(archive) : null; + if (!expectedHash || actualHash !== expectedHash) { + const reason = expectedHash ? "SHA-256 mismatch" : "not referenced by the current Cargo locks"; + warn("Cargo cache entry rejected", `${archiveName}: ${reason}; Cargo will download it normally.`); + await fs.rm(archive, { force: true }); + removed += 1; + } else { + verified += 1; + } + } + process.stdout.write(`Cargo download cache verified: ${verified} archive(s), ${removed} rejected.\n`); +} + +function safeVersion(value) { + return String(value).replaceAll(/[^A-Za-z0-9._-]/g, "_"); +} + +async function verifyJdtlsCache(cacheRoot, manifestPath) { + if (!cacheRoot || !manifestPath) return; + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + const expected = new Map([ + [`jdtls-${safeVersion(manifest.version)}-${manifest.archiveSHA256.toLowerCase()}.tar.gz`, manifest.archiveSHA256], + [`EPL-2.0-${manifest.licenseSHA256.toLowerCase()}.txt`, manifest.licenseSHA256], + [`lombok-${safeVersion(manifest.lombokVersion)}-${manifest.lombokSHA256.toLowerCase()}.jar`, manifest.lombokSHA256], + [`lombok-MIT-${safeVersion(manifest.lombokVersion)}-${manifest.lombokLicenseSHA256.toLowerCase()}.txt`, manifest.lombokLicenseSHA256], + ]); + let verified = 0; + let removed = 0; + for (const artifact of await collectFiles(cacheRoot)) { + const artifactName = path.basename(artifact); + const expectedHash = expected.get(artifactName)?.toLowerCase(); + const actualHash = expectedHash ? await sha256(artifact) : null; + if (!expectedHash || actualHash !== expectedHash) { + const reason = expectedHash ? "SHA-256 mismatch" : "not referenced by the JDTLS manifest"; + warn("JDTLS cache entry rejected", `${artifactName}: ${reason}; it will be downloaded normally.`); + await fs.rm(artifact, { force: true }); + removed += 1; + } else { + verified += 1; + } + } + process.stdout.write(`JDTLS download cache verified: ${verified} artifact(s), ${removed} rejected.\n`); +} + +async function verifyBunIdentity(expectedVersion, lockPath) { + if (!expectedVersion || !lockPath) return; + const result = spawnSync("bun", ["--version"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`Could not query Bun version: ${result.stderr || result.error}`); + const actualVersion = result.stdout.trim(); + if (actualVersion !== expectedVersion) { + throw new Error(`Bun ${expectedVersion} is required, but ${actualVersion} is active`); + } + const lockHash = await sha256(lockPath); + process.stdout.write(`Bun cache identity verified: version ${actualVersion}, lock SHA-256 ${lockHash}.\n`); + return { actualVersion, lockHash }; +} + +async function bunCacheFiles(cacheRoot) { + const manifestName = ".lithe-integrity.json"; + const files = []; + if (!(await exists(cacheRoot))) return files; + const rootStatus = await fs.lstat(cacheRoot); + if (rootStatus.isSymbolicLink() || !rootStatus.isDirectory()) { + throw new Error(`Bun cache root must be a real directory: ${cacheRoot}`); + } + const pending = [cacheRoot]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Bun cache contains a symbolic link and cannot be trusted: ${candidate}`); + } + if (entry.isDirectory()) pending.push(candidate); + else if (entry.isFile() && entry.name !== manifestName) files.push(candidate); + else if (!entry.isFile()) throw new Error(`Bun cache contains an unsupported entry: ${candidate}`); + } + } + return files.sort((left, right) => left.localeCompare(right)); +} + +async function clearBunCache(cacheRoot, reason) { + warn("Bun cache rejected", `${reason}; dependencies will be downloaded normally.`); + await fs.rm(cacheRoot, { force: true, recursive: true }); + await fs.mkdir(cacheRoot, { recursive: true }); +} + +async function writeBunManifest(cacheRoot, identity) { + await fs.mkdir(cacheRoot, { recursive: true }); + const files = {}; + for (const filePath of await bunCacheFiles(cacheRoot)) { + const relative = path.relative(cacheRoot, filePath).split(path.sep).join("/"); + files[relative] = await sha256(filePath); + } + const manifest = { + schemaVersion: 1, + bunVersion: identity.actualVersion, + lockSha256: identity.lockHash, + files, + }; + await fs.writeFile( + path.join(cacheRoot, ".lithe-integrity.json"), + `${JSON.stringify(manifest)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + process.stdout.write(`Bun download cache sealed: ${Object.keys(files).length} file(s).\n`); +} + +async function verifyBunCache(cacheRoot, identity) { + if (!(await exists(cacheRoot))) return; + const manifestPath = path.join(cacheRoot, ".lithe-integrity.json"); + const actualFiles = await bunCacheFiles(cacheRoot); + if (!(await exists(manifestPath))) { + if (actualFiles.length > 0) await clearBunCache(cacheRoot, "integrity manifest is missing"); + return; + } + + let manifest; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + } catch (error) { + await clearBunCache(cacheRoot, `integrity manifest is invalid (${error.message})`); + return; + } + if ( + manifest.schemaVersion !== 1 || + manifest.bunVersion !== identity.actualVersion || + manifest.lockSha256 !== identity.lockHash || + !manifest.files || + typeof manifest.files !== "object" + ) { + await clearBunCache(cacheRoot, "version or lock SHA-256 does not match the current build"); + return; + } + + const expectedNames = Object.keys(manifest.files).sort((left, right) => left.localeCompare(right)); + const actualNames = actualFiles.map((filePath) => path.relative(cacheRoot, filePath).split(path.sep).join("/")); + if (expectedNames.length !== actualNames.length || expectedNames.some((name, index) => name !== actualNames[index])) { + await clearBunCache(cacheRoot, "file set does not match the integrity manifest"); + return; + } + for (let index = 0; index < actualFiles.length; index += 1) { + const actualHash = await sha256(actualFiles[index]); + if (actualHash !== manifest.files[actualNames[index]]) { + await clearBunCache(cacheRoot, `${actualNames[index]} has a SHA-256 mismatch`); + return; + } + } + process.stdout.write(`Bun download cache verified: ${actualFiles.length} file(s).\n`); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + await verifyCargoCache(path.resolve(options.cargoCache), options.cargoLocks.map((item) => path.resolve(item))); + await verifyJdtlsCache( + options.jdtlsCache ? path.resolve(options.jdtlsCache) : null, + options.jdtlsManifest ? path.resolve(options.jdtlsManifest) : null, + ); + const bunIdentity = await verifyBunIdentity( + options.bunVersion, + options.bunLock ? path.resolve(options.bunLock) : null, + ); + if (bunIdentity && options.bunCache) { + const bunCache = path.resolve(options.bunCache); + if (options.writeBunManifest) await writeBunManifest(bunCache, bunIdentity); + else await verifyBunCache(bunCache, bunIdentity); + } +} + +main().catch((error) => { + process.stderr.write(`${error.stack || error}\n`); + process.exitCode = 1; +}); diff --git a/windows/tauri/rust-toolchain.toml b/windows/tauri/rust-toolchain.toml index 5d56faf9a..292fe499e 100644 --- a/windows/tauri/rust-toolchain.toml +++ b/windows/tauri/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly" +channel = "stable" From 6bafff8bb8f4b4b8cb74ffed203190afe88c736b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 22 Aug 2026 12:33:57 +0800 Subject: [PATCH 2/5] ci(macos): add verified dependency caches --- .../prepare-macos-dependency-cache/action.yml | 139 ++++++++++ .github/workflows/ci-database.yml | 10 +- .github/workflows/ci-macos.yml | 39 +-- .github/workflows/ci-plugins.yml | 10 +- .github/workflows/release-macos.yml | 21 +- .github/workflows/release-preview-macos.yml | 20 +- scripts/classify-ci-changes.sh | 25 +- .../install-windows-frontend-dependencies.ps1 | 2 +- scripts/prepare-jdtls.sh | 13 +- scripts/prepare-macos-dependencies.sh | 63 +++++ scripts/test-classify-ci-changes.sh | 15 +- ...che.mjs => test-verify-download-cache.mjs} | 70 ++++- scripts/validate-macos-dependency-caches.sh | 105 ++++++++ scripts/validate-windows-build-caches.ps1 | 2 +- ...ad-cache.mjs => verify-download-cache.mjs} | 249 +++++++++++++++++- 15 files changed, 700 insertions(+), 83 deletions(-) create mode 100644 .github/actions/prepare-macos-dependency-cache/action.yml create mode 100755 scripts/prepare-macos-dependencies.sh rename scripts/{test-verify-windows-download-cache.mjs => test-verify-download-cache.mjs} (57%) create mode 100755 scripts/validate-macos-dependency-caches.sh rename scripts/{verify-windows-download-cache.mjs => verify-download-cache.mjs} (54%) diff --git a/.github/actions/prepare-macos-dependency-cache/action.yml b/.github/actions/prepare-macos-dependency-cache/action.yml new file mode 100644 index 000000000..bffb23e49 --- /dev/null +++ b/.github/actions/prepare-macos-dependency-cache/action.yml @@ -0,0 +1,139 @@ +name: Prepare verified macOS dependency caches +description: Restore, validate, and safely fall back from repository-scoped macOS dependency caches. + +inputs: + swiftpm: + description: Restore and resolve SwiftPM dependency repositories. + required: false + default: "false" + cargo: + description: Restore Cargo crate archives. + required: false + default: "false" + jdtls: + description: Restore JDTLS and Lombok downloads. + required: false + default: "false" + restore-only: + description: Restore caches without saving them at the end of the job. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Configure isolated dependency caches + shell: zsh {0} + run: | + mkdir -p "$GITHUB_WORKSPACE/.artifacts" + { + echo "CARGO_HOME=$GITHUB_WORKSPACE/.artifacts/cargo-home" + echo "LITHE_SWIFTPM_CACHE_PATH=$GITHUB_WORKSPACE/.artifacts/swiftpm-cache" + } >> "$GITHUB_ENV" + + - name: Restore SwiftPM repositories + id: swiftpm_cache + if: inputs.swiftpm == 'true' && inputs.restore-only != 'true' + continue-on-error: true + uses: actions/cache@v6 + with: + path: | + .artifacts/swiftpm-cache/repositories + .artifacts/swiftpm-cache/.lithe-integrity.json + key: macos-swiftpm-repositories-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} + restore-keys: | + macos-swiftpm-repositories-${{ runner.os }}-6.2- + + - name: Restore SwiftPM repositories without saving + id: swiftpm_restore + if: inputs.swiftpm == 'true' && inputs.restore-only == 'true' + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: | + .artifacts/swiftpm-cache/repositories + .artifacts/swiftpm-cache/.lithe-integrity.json + key: macos-swiftpm-repositories-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} + restore-keys: | + macos-swiftpm-repositories-${{ runner.os }}-6.2- + + - name: Restore Cargo crate archives + id: cargo_cache + if: inputs.cargo == 'true' && inputs.restore-only != 'true' + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/cargo-home/registry/cache + key: macos-cargo-crates-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + macos-cargo-crates-${{ runner.os }}- + + - name: Restore Cargo crate archives without saving + id: cargo_restore + if: inputs.cargo == 'true' && inputs.restore-only == 'true' + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: .artifacts/cargo-home/registry/cache + key: macos-cargo-crates-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + macos-cargo-crates-${{ runner.os }}- + + - name: Restore JDTLS downloads + id: jdtls_cache + if: inputs.jdtls == 'true' && inputs.restore-only != 'true' + continue-on-error: true + uses: actions/cache@v6 + with: + path: .artifacts/jdtls-downloads + key: macos-jdtls-downloads-${{ runner.os }}-${{ hashFiles('third_party/jdtls/manifest.json') }} + restore-keys: | + macos-jdtls-downloads-${{ runner.os }}- + + - name: Restore JDTLS downloads without saving + id: jdtls_restore + if: inputs.jdtls == 'true' && inputs.restore-only == 'true' + continue-on-error: true + uses: actions/cache/restore@v6 + with: + path: .artifacts/jdtls-downloads + key: macos-jdtls-downloads-${{ runner.os }}-${{ hashFiles('third_party/jdtls/manifest.json') }} + restore-keys: | + macos-jdtls-downloads-${{ runner.os }}- + + - name: Validate restored caches or fall back + shell: zsh {0} + env: + RESTORE_ONLY: ${{ inputs.restore-only }} + SWIFTPM_CACHE_OUTCOME: ${{ steps.swiftpm_cache.outcome }} + SWIFTPM_CACHE_HIT: ${{ steps.swiftpm_cache.outputs.cache-hit }} + SWIFTPM_RESTORE_OUTCOME: ${{ steps.swiftpm_restore.outcome }} + SWIFTPM_RESTORE_HIT: ${{ steps.swiftpm_restore.outputs.cache-hit }} + CARGO_CACHE_OUTCOME: ${{ steps.cargo_cache.outcome }} + CARGO_RESTORE_OUTCOME: ${{ steps.cargo_restore.outcome }} + JDTLS_CACHE_OUTCOME: ${{ steps.jdtls_cache.outcome }} + JDTLS_RESTORE_OUTCOME: ${{ steps.jdtls_restore.outcome }} + run: | + swiftpm_outcome="$SWIFTPM_CACHE_OUTCOME" + swiftpm_hit="$SWIFTPM_CACHE_HIT" + cargo_outcome="$CARGO_CACHE_OUTCOME" + jdtls_outcome="$JDTLS_CACHE_OUTCOME" + if [[ "$RESTORE_ONLY" == "true" ]]; then + swiftpm_outcome="$SWIFTPM_RESTORE_OUTCOME" + swiftpm_hit="$SWIFTPM_RESTORE_HIT" + cargo_outcome="$CARGO_RESTORE_OUTCOME" + jdtls_outcome="$JDTLS_RESTORE_OUTCOME" + fi + "$GITHUB_WORKSPACE/scripts/validate-macos-dependency-caches.sh" \ + --swiftpm-enabled "${{ inputs.swiftpm }}" \ + --swiftpm-outcome "${swiftpm_outcome:-skipped}" \ + --swiftpm-hit "${swiftpm_hit:-}" \ + --cargo-enabled "${{ inputs.cargo }}" \ + --cargo-outcome "${cargo_outcome:-skipped}" \ + --jdtls-enabled "${{ inputs.jdtls }}" \ + --jdtls-outcome "${jdtls_outcome:-skipped}" + + - name: Resolve SwiftPM dependencies with fallback + if: inputs.swiftpm == 'true' + shell: zsh {0} + run: "$GITHUB_WORKSPACE/scripts/prepare-macos-dependencies.sh" diff --git a/.github/workflows/ci-database.yml b/.github/workflows/ci-database.yml index d1e58ecb1..57256f0f2 100644 --- a/.github/workflows/ci-database.yml +++ b/.github/workflows/ci-database.yml @@ -97,14 +97,10 @@ jobs: with: swift-version: "6.2" - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified SwiftPM cache + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} + swiftpm: "true" - name: Run database-focused Swift tests timeout-minutes: 12 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index aacfadd4e..835b6bd6b 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -136,14 +136,10 @@ jobs: with: swift-version: "6.2" - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified SwiftPM cache + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} + swiftpm: "true" - name: Run Swift unit tests timeout-minutes: 12 @@ -181,13 +177,11 @@ jobs: with: components: rustfmt - - name: Restore Cargo dependencies - uses: actions/cache@v6 + - name: Prepare verified dependency caches + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + swiftpm: "true" + cargo: "true" - name: Run Rust Core tests and verify the Swift bridge run: ./scripts/verify-rust-core.sh @@ -213,22 +207,11 @@ jobs: with: targets: aarch64-apple-darwin,x86_64-apple-darwin - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified dependency caches + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} - - - name: Restore Cargo dependencies - uses: actions/cache@v6 - with: - path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + swiftpm: "true" + cargo: "true" - name: Check packaging scripts and metadata shell: zsh {0} diff --git a/.github/workflows/ci-plugins.yml b/.github/workflows/ci-plugins.yml index f5e2203a9..2addd5170 100644 --- a/.github/workflows/ci-plugins.yml +++ b/.github/workflows/ci-plugins.yml @@ -91,14 +91,10 @@ jobs: with: swift-version: "6.2" - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified SwiftPM cache + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} + swiftpm: "true" - name: Run plugin-focused Swift tests timeout-minutes: 12 diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 281aa2bce..e3cd2a1f6 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -79,22 +79,13 @@ jobs: with: targets: ${{ matrix.architecture == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }} - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified dependency caches + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} - - - name: Restore Cargo dependencies - uses: actions/cache@v6 - with: - path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + swiftpm: "true" + cargo: "true" + jdtls: "true" + restore-only: "true" - name: Build and package the App env: diff --git a/.github/workflows/release-preview-macos.yml b/.github/workflows/release-preview-macos.yml index b8886e5a8..846d95eb0 100644 --- a/.github/workflows/release-preview-macos.yml +++ b/.github/workflows/release-preview-macos.yml @@ -58,22 +58,12 @@ jobs: with: targets: ${{ matrix.architecture == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }} - - name: Restore SwiftPM dependencies - uses: actions/cache@v6 + - name: Prepare verified dependency caches + uses: ./.github/actions/prepare-macos-dependency-cache with: - path: | - .build/checkouts - .build/repositories - ~/.cache/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} - - - name: Restore Cargo dependencies - uses: actions/cache@v6 - with: - path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} + swiftpm: "true" + cargo: "true" + jdtls: "true" - name: Build and package the preview app env: diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 672f47f80..b2fa09462 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -105,6 +105,13 @@ while IFS=$'\t' read -r status first_path _; do .github/workflows/release-windows.yml|.github/workflows/release-preview-windows.yml) windows=true ;; + .github/actions/prepare-macos-dependency-cache/*) + swift=true + plugins=true + swift_database=true + rust_core=true + macos_release=true + ;; .github/*|docs/*|.agents/*|.idea/*|.gitignore|license) ;; package.swift|package.resolved) @@ -289,10 +296,26 @@ while IFS=$'\t' read -r status first_path _; do scripts/build-macos.sh|scripts/verify-macos-app-build-safety.sh|scripts/verify-macos-package.sh|scripts/macos13sdkcompatibility.h|scripts/ld-macos13-compat.sh|scripts/package-app.sh|scripts/preview.sh|scripts/stamp-macos-app-build-info.sh|scripts/create-dmg.sh|scripts/create-macos-update-manifest.rb|scripts/test-macos-update-manifest.rb|scripts/prepare-jdtls.sh) macos_release=true ;; - scripts/verify-windows-download-cache.mjs|scripts/test-verify-windows-download-cache.mjs|scripts/validate-windows-build-caches.ps1|scripts/invoke-cargo-with-cache-fallback.ps1) + scripts/verify-download-cache.mjs|scripts/test-verify-download-cache.mjs) + swift=true + plugins=true + swift_database=true + rust_core=true + macos_release=true + windows=true + windows_rust=true + ;; + scripts/validate-windows-build-caches.ps1|scripts/invoke-cargo-with-cache-fallback.ps1) windows=true windows_rust=true ;; + scripts/validate-macos-dependency-caches.sh|scripts/prepare-macos-dependencies.sh) + swift=true + plugins=true + swift_database=true + rust_core=true + macos_release=true + ;; scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) windows=true ;; diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 index eb3dcf047..e013ea6ee 100644 --- a/scripts/install-windows-frontend-dependencies.ps1 +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -46,7 +46,7 @@ try { if ($env:LITHE_BUN_CACHE_VERIFIED -ne "true" -or -not (Test-Path -LiteralPath (Join-Path $bunCache ".lithe-integrity.json") -PathType Leaf)) { - & node (Join-Path $root "scripts/verify-windows-download-cache.mjs") ` + & node (Join-Path $root "scripts/verify-download-cache.mjs") ` --cargo-cache (Join-Path $root ".artifacts/cargo-home/registry/cache") ` --cargo-lock (Join-Path $root "rust/Cargo.lock") ` --cargo-lock (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") ` diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index 2ae7bb211..e487355f4 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -30,6 +30,17 @@ file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' } +cache_warning() { + local message="$1" + print -u2 -- "warning: $message" + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + message="${message//'%'/'%25'}" + message="${message//$'\r'/'%0D'}" + message="${message//$'\n'/'%0A'}" + print -- "::warning title=JDTLS cache fallback::$message" + fi +} + download_verified_file() { local url="$1" local expected_sha256="$2" @@ -43,7 +54,7 @@ download_verified_file() { if [[ "$actual_sha256" == "$expected_sha256" ]]; then return 0 fi - print -u2 -- "$description cache checksum mismatch; removing it before retrying the download" + cache_warning "$description cache checksum mismatch; removing it before retrying the download" rm -f -- "$destination" fi diff --git a/scripts/prepare-macos-dependencies.sh b/scripts/prepare-macos-dependencies.sh new file mode 100755 index 000000000..09bac18ba --- /dev/null +++ b/scripts/prepare-macos-dependencies.sh @@ -0,0 +1,63 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +CACHE_ROOT="${LITHE_SWIFTPM_CACHE_PATH:-$ROOT_DIR/.artifacts/swiftpm-cache}" +CACHE_ROOT="${CACHE_ROOT:A}" +ARTIFACT_ROOT="$ROOT_DIR/.artifacts" +ARTIFACT_ROOT="${ARTIFACT_ROOT:A}" + +if [[ "$CACHE_ROOT" != "$ARTIFACT_ROOT"/* ]]; then + print -u2 -- "SwiftPM cache must remain inside the repository artifact root: $CACHE_ROOT" + exit 2 +fi + +warning() { + local title="$1" + local message="$2" + print -u2 -- "warning: $title: $message" + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + message="${message//'%'/'%25'}" + message="${message//$'\r'/'%0D'}" + message="${message//$'\n'/'%0A'}" + print -- "::warning title=$title::$message" + fi +} + +clear_dependency_state() { + rm -rf -- \ + "$CACHE_ROOT" \ + "$ROOT_DIR/.build/checkouts" \ + "$ROOT_DIR/.build/repositories" \ + "$ROOT_DIR/.build/workspace-state.json" + mkdir -p -- "$CACHE_ROOT" +} + +resolve_dependencies() { + swift package \ + --cache-path "$CACHE_ROOT" \ + --only-use-versions-from-resolved-file \ + resolve +} + +cd "$ROOT_DIR" +mkdir -p -- "$CACHE_ROOT" +if ! resolve_dependencies; then + if [[ "${LITHE_SWIFTPM_CACHE_RESTORED:-false}" != "true" ]]; then + exit 1 + fi + warning "SwiftPM cache fallback" "Dependency resolution failed after a cache restore. Clearing repository-scoped dependency state and retrying once with an empty cache." + clear_dependency_state + resolve_dependencies +fi + +if ! node "$ROOT_DIR/scripts/verify-download-cache.mjs" \ + --skip-cargo \ + --swiftpm-cache "$CACHE_ROOT" \ + --swiftpm-resolved "$ROOT_DIR/Package.resolved" \ + --swift-version 6.2 \ + --write-swiftpm-manifest; then + warning "SwiftPM cache sealing failed" "Resolved checkouts remain available for this job, but the shared cache will be cleared instead of saving unverifiable content." + rm -rf -- "$CACHE_ROOT" + mkdir -p -- "$CACHE_ROOT" +fi diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index d375f6a2c..f71feb042 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -130,7 +130,11 @@ modify_mixed_core_logic_test() { printf '%s\n' 'struct UpdatedLitheCoreLogicTest modify_shared_fixture() { printf '%s\n' '{"operation":"updated"}' > shared/fixtures/core/test.json; } modify_windows_frontend() { printf '%s\n' 'export const value = 2;' > windows/tauri/src/value.ts; } modify_windows_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > windows/tauri/src-tauri/src/main.rs; } -modify_windows_cache_validator() { printf '%s\n' 'console.log("updated");' > scripts/verify-windows-download-cache.mjs; } +modify_download_cache_validator() { printf '%s\n' 'console.log("updated");' > scripts/verify-download-cache.mjs; } +modify_macos_cache_action() { + mkdir -p .github/actions/prepare-macos-dependency-cache + printf '%s\n' 'name: updated' > .github/actions/prepare-macos-dependency-cache/action.yml +} modify_metadata() { printf '%s\n' 'cask "lithe" do' ' version "1.0.0"' 'end' > Casks/lithe.rb; } modify_classifier() { printf '%s\n' '# classifier test change' >> scripts/classify-ci-changes.sh; } modify_swift_and_database() { @@ -220,9 +224,12 @@ assert_classification windows-frontend \ assert_classification windows-rust \ "$(classification false false false false false false true true false false)" \ modify_windows_rust -assert_classification windows-cache-validator \ - "$(classification false false false false false false true true false false)" \ - modify_windows_cache_validator +assert_classification download-cache-validator \ + "$(classification true true true true false true true true false false)" \ + modify_download_cache_validator +assert_classification macos-cache-action \ + "$(classification true true true true false true false false false false)" \ + modify_macos_cache_action assert_classification metadata \ "$(classification false false false false false false false false false true)" \ modify_metadata diff --git a/scripts/test-verify-windows-download-cache.mjs b/scripts/test-verify-download-cache.mjs similarity index 57% rename from scripts/test-verify-windows-download-cache.mjs rename to scripts/test-verify-download-cache.mjs index 121b7aba1..5db6af4f4 100644 --- a/scripts/test-verify-windows-download-cache.mjs +++ b/scripts/test-verify-download-cache.mjs @@ -8,7 +8,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); -const verifier = path.join(scriptDirectory, "verify-windows-download-cache.mjs"); +const verifier = path.join(scriptDirectory, "verify-download-cache.mjs"); const emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lithe-cache-verifier-")); @@ -16,6 +16,12 @@ function verify(argumentsList, environment = process.env) { return spawnSync(process.execPath, [verifier, ...argumentsList], { encoding: "utf8", env: environment }); } +function run(command, argumentsList, workingDirectory = testRoot) { + const result = spawnSync(command, argumentsList, { cwd: workingDirectory, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +} + try { const cargoCache = path.join(testRoot, "cargo-cache", "registry"); const cargoLock = path.join(testRoot, "Cargo.lock"); @@ -108,7 +114,67 @@ try { assert.match(result.stderr, /SHA-256 mismatch/); await assert.rejects(fs.access(cachedPackage)); - process.stdout.write("Windows download cache verifier tests passed.\n"); + const swiftSource = path.join(testRoot, "swift-source"); + const swiftpmCache = path.join(testRoot, "swiftpm-cache"); + const swiftRepositoryRoot = path.join(swiftpmCache, "repositories"); + const swiftRepository = path.join(swiftRepositoryRoot, "fixture-dependency-deadbeef"); + const swiftResolved = path.join(testRoot, "Package.resolved"); + const swiftRemote = "https://github.com/example/fixture-dependency.git"; + await fs.mkdir(swiftSource, { recursive: true }); + run("git", ["init", "--quiet"], swiftSource); + run("git", ["config", "user.name", "Cache Test"], swiftSource); + run("git", ["config", "user.email", "cache-test@example.invalid"], swiftSource); + await fs.writeFile(path.join(swiftSource, "Package.swift"), "// swift-tools-version: 6.2\n"); + run("git", ["add", "Package.swift"], swiftSource); + run("git", ["commit", "--quiet", "-m", "fixture"], swiftSource); + const swiftRevision = run("git", ["rev-parse", "HEAD"], swiftSource); + await fs.mkdir(swiftRepositoryRoot, { recursive: true }); + run("git", ["clone", "--quiet", "--mirror", swiftSource, swiftRepository]); + run("git", ["remote", "set-url", "origin", swiftRemote], swiftRepository); + await fs.writeFile( + swiftResolved, + JSON.stringify({ + originHash: "fixture", + pins: [{ + identity: "fixture-dependency", + kind: "remoteSourceControl", + location: swiftRemote, + state: { revision: swiftRevision, version: "1.0.0" }, + }], + version: 3, + }), + ); + const swiftpmArguments = [ + "--skip-cargo", + "--swiftpm-cache", + swiftpmCache, + "--swiftpm-resolved", + swiftResolved, + "--swift-version", + "6.2", + ]; + result = verify([...swiftpmArguments, "--write-swiftpm-manifest"]); + assert.equal(result.status, 0, result.stderr); + await fs.access(path.join(swiftpmCache, ".lithe-integrity.json")); + result = verify(swiftpmArguments); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SwiftPM dependency cache verified: 1 repository/); + + const changedResolved = JSON.parse(await fs.readFile(swiftResolved, "utf8")); + changedResolved.originHash = "changed-fixture"; + await fs.writeFile(swiftResolved, JSON.stringify(changedResolved)); + result = verify(swiftpmArguments); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Package\.resolved SHA-256 changed/); + await fs.access(swiftRepository); + + await fs.writeFile(path.join(swiftRepository, "tampered"), "tampered\n"); + result = verify(swiftpmArguments); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /SHA-256 mismatch|file set does not match/); + await assert.rejects(fs.access(swiftRepository)); + + process.stdout.write("Download cache verifier tests passed.\n"); } finally { await fs.rm(testRoot, { force: true, recursive: true }); } diff --git a/scripts/validate-macos-dependency-caches.sh b/scripts/validate-macos-dependency-caches.sh new file mode 100755 index 000000000..2280a4184 --- /dev/null +++ b/scripts/validate-macos-dependency-caches.sh @@ -0,0 +1,105 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +SWIFTPM_ENABLED=false +SWIFTPM_OUTCOME=skipped +SWIFTPM_HIT="" +CARGO_ENABLED=false +CARGO_OUTCOME=skipped +JDTLS_ENABLED=false +JDTLS_OUTCOME=skipped + +while [[ $# -gt 0 ]]; do + case "$1" in + --swiftpm-enabled) SWIFTPM_ENABLED="$2"; shift 2 ;; + --swiftpm-outcome) SWIFTPM_OUTCOME="$2"; shift 2 ;; + --swiftpm-hit) SWIFTPM_HIT="$2"; shift 2 ;; + --cargo-enabled) CARGO_ENABLED="$2"; shift 2 ;; + --cargo-outcome) CARGO_OUTCOME="$2"; shift 2 ;; + --jdtls-enabled) JDTLS_ENABLED="$2"; shift 2 ;; + --jdtls-outcome) JDTLS_OUTCOME="$2"; shift 2 ;; + *) print -u2 -- "Unknown option: $1"; exit 2 ;; + esac +done + +warning() { + local title="$1" + local message="$2" + print -u2 -- "warning: $title: $message" + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + message="${message//'%'/'%25'}" + message="${message//$'\r'/'%0D'}" + message="${message//$'\n'/'%0A'}" + print -- "::warning title=$title::$message" + fi +} + +clear_artifact_cache() { + local candidate="${1:A}" + local artifact_root="$ROOT_DIR/.artifacts" + artifact_root="${artifact_root:A}" + if [[ "$candidate" != "$artifact_root"/* ]]; then + print -u2 -- "Refusing to clear a cache outside the repository artifact root: $candidate" + return 1 + fi + rm -rf -- "$candidate" + mkdir -p -- "$candidate" +} + +swiftpm_root="$ROOT_DIR/.artifacts/swiftpm-cache" +cargo_cache="$ROOT_DIR/.artifacts/cargo-home/registry/cache" +jdtls_cache="$ROOT_DIR/.artifacts/jdtls-downloads" + +if [[ "$SWIFTPM_ENABLED" == "true" && "$SWIFTPM_OUTCOME" != "success" ]]; then + warning "SwiftPM cache restore failed" "GitHub cache restore reported $SWIFTPM_OUTCOME. The isolated cache will be discarded and dependencies will be resolved normally." + clear_artifact_cache "$swiftpm_root" +fi +if [[ "$CARGO_ENABLED" == "true" && "$CARGO_OUTCOME" != "success" ]]; then + warning "Cargo cache restore failed" "GitHub cache restore reported $CARGO_OUTCOME. The isolated cache will be discarded and crates will be downloaded normally." + clear_artifact_cache "$cargo_cache" +fi +if [[ "$JDTLS_ENABLED" == "true" && "$JDTLS_OUTCOME" != "success" ]]; then + warning "JDTLS cache restore failed" "GitHub cache restore reported $JDTLS_OUTCOME. The isolated cache will be discarded and artifacts will be downloaded normally." + clear_artifact_cache "$jdtls_cache" +fi + +verifier_arguments=() +if [[ "$CARGO_ENABLED" == "true" ]]; then + verifier_arguments+=( + --cargo-cache "$cargo_cache" + --cargo-lock "$ROOT_DIR/rust/Cargo.lock" + ) +else + verifier_arguments+=(--skip-cargo) +fi +if [[ "$SWIFTPM_ENABLED" == "true" ]]; then + verifier_arguments+=( + --swiftpm-cache "$swiftpm_root" + --swiftpm-resolved "$ROOT_DIR/Package.resolved" + --swift-version 6.2 + ) +fi +if [[ "$JDTLS_ENABLED" == "true" ]]; then + verifier_arguments+=( + --jdtls-cache "$jdtls_cache" + --jdtls-manifest "$ROOT_DIR/third_party/jdtls/manifest.json" + ) +fi + +if ! node "$ROOT_DIR/scripts/verify-download-cache.mjs" "${verifier_arguments[@]}"; then + warning "Dependency cache validation failed" "The cache validator failed unexpectedly. All enabled caches will be cleared before the normal dependency path continues." + [[ "$SWIFTPM_ENABLED" == "true" ]] && clear_artifact_cache "$swiftpm_root" + [[ "$CARGO_ENABLED" == "true" ]] && clear_artifact_cache "$cargo_cache" + [[ "$JDTLS_ENABLED" == "true" ]] && clear_artifact_cache "$jdtls_cache" +fi + +if [[ -n "${GITHUB_ENV:-}" ]]; then + swiftpm_restored=false + if [[ "$SWIFTPM_ENABLED" == "true" && "$SWIFTPM_OUTCOME" == "success" && -n "$SWIFTPM_HIT" ]]; then + swiftpm_restored=true + fi + print -- "LITHE_SWIFTPM_CACHE_RESTORED=$swiftpm_restored" >> "$GITHUB_ENV" +fi + +exit 0 diff --git a/scripts/validate-windows-build-caches.ps1 b/scripts/validate-windows-build-caches.ps1 index 5e0c74280..f044bd23e 100644 --- a/scripts/validate-windows-build-caches.ps1 +++ b/scripts/validate-windows-build-caches.ps1 @@ -62,7 +62,7 @@ if ($JdtlsOutcome -eq "failure") { } $validatorArguments = @( - (Join-Path $root "scripts/verify-windows-download-cache.mjs"), + (Join-Path $root "scripts/verify-download-cache.mjs"), "--cargo-cache", (Join-Path $cargoHome "registry/cache"), "--cargo-lock", (Join-Path $root "rust/Cargo.lock"), "--cargo-lock", (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") diff --git a/scripts/verify-windows-download-cache.mjs b/scripts/verify-download-cache.mjs similarity index 54% rename from scripts/verify-windows-download-cache.mjs rename to scripts/verify-download-cache.mjs index 2e66ac794..b0a21112a 100644 --- a/scripts/verify-windows-download-cache.mjs +++ b/scripts/verify-download-cache.mjs @@ -26,8 +26,13 @@ function parseArguments(argv) { const options = { cargoCache: path.join(REPOSITORY_ROOT, ".artifacts", "cargo-home", "registry", "cache"), cargoLocks: [], + skipCargo: false, jdtlsCache: null, jdtlsManifest: null, + swiftpmCache: null, + swiftpmResolved: null, + swiftVersion: null, + writeSwiftpmManifest: false, bunVersion: null, bunLock: null, bunCache: null, @@ -40,6 +45,14 @@ function parseArguments(argv) { options.writeBunManifest = true; continue; } + if (option === "--write-swiftpm-manifest") { + options.writeSwiftpmManifest = true; + continue; + } + if (option === "--skip-cargo") { + options.skipCargo = true; + continue; + } const value = argv[index + 1]; if (!value) throw new Error(`${option} requires a value`); index += 1; @@ -47,6 +60,9 @@ function parseArguments(argv) { else if (option === "--cargo-lock") options.cargoLocks.push(value); else if (option === "--jdtls-cache") options.jdtlsCache = value; else if (option === "--jdtls-manifest") options.jdtlsManifest = value; + else if (option === "--swiftpm-cache") options.swiftpmCache = value; + else if (option === "--swiftpm-resolved") options.swiftpmResolved = value; + else if (option === "--swift-version") options.swiftVersion = value; else if (option === "--bun-version") options.bunVersion = value; else if (option === "--bun-lock") options.bunLock = value; else if (option === "--bun-cache") options.bunCache = value; @@ -190,6 +206,229 @@ async function verifyJdtlsCache(cacheRoot, manifestPath) { process.stdout.write(`JDTLS download cache verified: ${verified} artifact(s), ${removed} rejected.\n`); } +function runGit(repository, argumentList) { + const result = spawnSync( + "git", + ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", repository, ...argumentList], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || result.error || "git command failed").toString().trim()); + } + return result.stdout.trim(); +} + +function normalizeRepositoryURL(value) { + return String(value).trim().replace(/\.git\/?$/i, "").replace(/\/$/, "").toLowerCase(); +} + +async function expectedSwiftRepositories(resolvedPath) { + const resolved = JSON.parse(await fs.readFile(resolvedPath, "utf8")); + const expected = new Map(); + for (const pin of resolved.pins ?? []) { + if (pin.kind !== "remoteSourceControl" || !pin.location || !pin.state?.revision) continue; + const normalizedURL = normalizeRepositoryURL(pin.location); + if (expected.has(normalizedURL)) throw new Error(`Duplicate SwiftPM repository in Package.resolved: ${pin.location}`); + expected.set(normalizedURL, { + identity: pin.identity, + location: pin.location, + revision: pin.state.revision.toLowerCase(), + version: pin.state.version ?? null, + }); + } + return expected; +} + +async function findUnsafeRepositoryEntry(repository) { + const pending = [repository]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isSymbolicLink()) return `symbolic link ${candidate}`; + if (entry.isDirectory()) pending.push(candidate); + else if (!entry.isFile()) return `unsupported filesystem entry ${candidate}`; + } + } + return null; +} + +function verifySafeGitConfiguration(repository) { + const allowedKeys = new Set([ + "core.repositoryformatversion", + "core.filemode", + "core.bare", + "core.ignorecase", + "core.precomposeunicode", + "core.symlinks", + "core.fsmonitor", + "core.longpaths", + "remote.origin.url", + "remote.origin.tagopt", + "remote.origin.fetch", + "remote.origin.mirror", + ]); + const keys = runGit(repository, ["config", "--local", "--name-only", "--list"]) + .split("\n") + .filter(Boolean); + const unsafeKey = keys.find((key) => !allowedKeys.has(key.toLowerCase())); + if (unsafeKey) throw new Error(`unsafe Git configuration key ${unsafeKey}`); + if (keys.some((key) => key.toLowerCase() === "core.fsmonitor")) { + const fsmonitor = runGit(repository, ["config", "--local", "--get", "core.fsmonitor"]); + if (fsmonitor !== "false") throw new Error("core.fsmonitor must be disabled"); + } +} + +async function verifySafeGitHooks(repository) { + const hooksDirectory = path.join(repository, "hooks"); + if (!(await exists(hooksDirectory))) return; + for (const entry of await fs.readdir(hooksDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".sample")) { + throw new Error(`unsafe Git hook entry ${path.join(hooksDirectory, entry.name)}`); + } + } +} + +async function cacheFilesForManifest(cacheRoot, manifestName) { + return (await collectFiles(cacheRoot)) + .filter((filePath) => path.relative(cacheRoot, filePath).split(path.sep).join("/") !== manifestName) + .sort((left, right) => left.localeCompare(right)); +} + +async function clearSwiftpmCache(cacheRoot, reason) { + warn("SwiftPM cache rejected", `${reason}; dependencies will be downloaded normally.`); + await fs.rm(cacheRoot, { force: true, recursive: true }); + await fs.mkdir(cacheRoot, { recursive: true }); +} + +async function swiftpmIdentity(resolvedPath, swiftVersion) { + return { + resolvedSha256: await sha256(resolvedPath), + swiftVersion, + }; +} + +async function verifySwiftpmManifest(cacheRoot, repositoriesRoot, identity) { + const manifestName = ".lithe-integrity.json"; + const manifestPath = path.join(cacheRoot, manifestName); + const actualFiles = await cacheFilesForManifest(repositoriesRoot, manifestName); + if (!(await exists(manifestPath))) { + if (actualFiles.length > 0) { + await clearSwiftpmCache(cacheRoot, "integrity manifest is missing"); + return false; + } + return true; + } + let manifest; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + } catch (error) { + await clearSwiftpmCache(cacheRoot, `integrity manifest is invalid (${error.message})`); + return false; + } + if (manifest.schemaVersion !== 1 || !manifest.files || typeof manifest.files !== "object") { + await clearSwiftpmCache(cacheRoot, "integrity manifest has an unsupported schema"); + return false; + } + if (manifest.swiftVersion !== identity.swiftVersion) { + await clearSwiftpmCache(cacheRoot, "Swift version does not match the current build"); + return false; + } + const expectedNames = Object.keys(manifest.files).sort((left, right) => left.localeCompare(right)); + const actualNames = actualFiles.map((filePath) => path.relative(repositoriesRoot, filePath).split(path.sep).join("/")); + if (expectedNames.length !== actualNames.length || expectedNames.some((name, index) => name !== actualNames[index])) { + await clearSwiftpmCache(cacheRoot, "file set does not match the integrity manifest"); + return false; + } + for (let index = 0; index < actualFiles.length; index += 1) { + if ((await sha256(actualFiles[index])) !== manifest.files[actualNames[index]]) { + await clearSwiftpmCache(cacheRoot, `${actualNames[index]} has a SHA-256 mismatch`); + return false; + } + } + if (manifest.resolvedSha256 !== identity.resolvedSha256) { + warn( + "SwiftPM dependency graph changed", + "Package.resolved SHA-256 changed; verified repositories will be filtered against the current pinned revisions.", + ); + } + return true; +} + +async function verifySwiftpmRepositories(cacheRoot, expected, requireAll) { + const matched = new Set(); + let removed = 0; + if (!(await exists(cacheRoot))) await fs.mkdir(cacheRoot, { recursive: true }); + const rootStatus = await fs.lstat(cacheRoot); + if (rootStatus.isSymbolicLink() || !rootStatus.isDirectory()) { + throw new Error(`SwiftPM cache root must be a real directory: ${cacheRoot}`); + } + for (const entry of await fs.readdir(cacheRoot, { withFileTypes: true })) { + if (entry.name === ".lithe-integrity.json") continue; + const repository = path.join(cacheRoot, entry.name); + try { + if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`unsafe cache entry ${repository}`); + const unsafeEntry = await findUnsafeRepositoryEntry(repository); + if (unsafeEntry) throw new Error(unsafeEntry); + await verifySafeGitHooks(repository); + verifySafeGitConfiguration(repository); + if (runGit(repository, ["rev-parse", "--is-bare-repository"]) !== "true") { + throw new Error(`${repository} is not a bare Git repository`); + } + const remoteURL = runGit(repository, ["remote", "get-url", "origin"]); + const normalizedURL = normalizeRepositoryURL(remoteURL); + const pin = expected.get(normalizedURL); + if (!pin) throw new Error(`${remoteURL} is not referenced by Package.resolved`); + if (matched.has(normalizedURL)) throw new Error(`duplicate cached repository ${remoteURL}`); + runGit(repository, ["fsck", "--full", "--no-dangling"]); + runGit(repository, ["cat-file", "-e", `${pin.revision}^{commit}`]); + matched.add(normalizedURL); + } catch (error) { + warn("SwiftPM cache entry rejected", `${entry.name}: ${error.message}; SwiftPM will fetch it normally.`); + await fs.rm(repository, { force: true, recursive: true }); + removed += 1; + } + } + if (requireAll) { + const missing = [...expected.keys()].filter((repositoryURL) => !matched.has(repositoryURL)); + if (missing.length > 0) throw new Error(`SwiftPM cache is missing ${missing.length} pinned repository/repositories`); + } + return { verified: matched.size, removed }; +} + +async function writeSwiftpmManifest(cacheRoot, repositoriesRoot, identity) { + const manifestName = ".lithe-integrity.json"; + const files = {}; + for (const filePath of await cacheFilesForManifest(repositoriesRoot, manifestName)) { + const relative = path.relative(repositoriesRoot, filePath).split(path.sep).join("/"); + files[relative] = await sha256(filePath); + } + await fs.writeFile( + path.join(cacheRoot, manifestName), + `${JSON.stringify({ schemaVersion: 1, ...identity, files })}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + process.stdout.write(`SwiftPM dependency cache sealed: ${Object.keys(files).length} file(s).\n`); +} + +async function verifySwiftpmCache(cacheRoot, resolvedPath, swiftVersion, writeManifest) { + if (!cacheRoot || !resolvedPath || !swiftVersion) return; + const identity = await swiftpmIdentity(resolvedPath, swiftVersion); + await fs.mkdir(cacheRoot, { recursive: true }); + const repositoriesRoot = path.join(cacheRoot, "repositories"); + await fs.mkdir(repositoriesRoot, { recursive: true }); + if (!writeManifest && !(await verifySwiftpmManifest(cacheRoot, repositoriesRoot, identity))) return; + const expected = await expectedSwiftRepositories(resolvedPath); + try { + const result = await verifySwiftpmRepositories(repositoriesRoot, expected, writeManifest); + if (writeManifest) await writeSwiftpmManifest(cacheRoot, repositoriesRoot, identity); + else process.stdout.write(`SwiftPM dependency cache verified: ${result.verified} repository/repositories, ${result.removed} rejected.\n`); + } catch (error) { + await clearSwiftpmCache(cacheRoot, error.message); + if (writeManifest) throw error; + } +} + async function verifyBunIdentity(expectedVersion, lockPath) { if (!expectedVersion || !lockPath) return; const result = spawnSync("bun", ["--version"], { encoding: "utf8" }); @@ -299,11 +538,19 @@ async function verifyBunCache(cacheRoot, identity) { async function main() { const options = parseArguments(process.argv.slice(2)); - await verifyCargoCache(path.resolve(options.cargoCache), options.cargoLocks.map((item) => path.resolve(item))); + if (!options.skipCargo) { + await verifyCargoCache(path.resolve(options.cargoCache), options.cargoLocks.map((item) => path.resolve(item))); + } await verifyJdtlsCache( options.jdtlsCache ? path.resolve(options.jdtlsCache) : null, options.jdtlsManifest ? path.resolve(options.jdtlsManifest) : null, ); + await verifySwiftpmCache( + options.swiftpmCache ? path.resolve(options.swiftpmCache) : null, + options.swiftpmResolved ? path.resolve(options.swiftpmResolved) : null, + options.swiftVersion, + options.writeSwiftpmManifest, + ); const bunIdentity = await verifyBunIdentity( options.bunVersion, options.bunLock ? path.resolve(options.bunLock) : null, From 8731112fd1d561c0e1912de408267a21ed368d67 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 22 Aug 2026 12:55:32 +0800 Subject: [PATCH 3/5] fix(ci): pin working Bun version on Windows --- .github/workflows/ci-windows.yml | 7 ++++--- .github/workflows/release-preview-windows.yml | 7 ++++--- .github/workflows/release-windows.yml | 7 ++++--- scripts/validate-windows-build-caches.ps1 | 7 ++++++- windows/tauri/package.json | 2 +- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 2c8706be8..b92377442 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -102,10 +102,11 @@ jobs: targets: x86_64-pc-windows-msvc - name: Set up Bun + id: bun if: needs.changes.outputs.windows == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.14" + bun-version-file: windows/tauri/package.json - name: Configure isolated dependency caches shell: pwsh @@ -162,9 +163,9 @@ jobs: uses: actions/cache@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- - name: Restore JDTLS downloads if: needs.changes.outputs.windows == 'true' diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml index a64928760..06ee18e6a 100644 --- a/.github/workflows/release-preview-windows.yml +++ b/.github/workflows/release-preview-windows.yml @@ -52,9 +52,10 @@ jobs: targets: x86_64-pc-windows-msvc - name: Set up Bun + id: bun uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.14" + bun-version-file: windows/tauri/package.json - name: Configure isolated dependency caches shell: pwsh @@ -110,9 +111,9 @@ jobs: uses: actions/cache@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- - name: Restore JDTLS downloads id: jdtls-download-cache diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index e8e3cd5fe..6baca7931 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -36,9 +36,10 @@ jobs: targets: x86_64-pc-windows-msvc - name: Set up Bun + id: bun uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.14" + bun-version-file: windows/tauri/package.json - name: Configure isolated dependency caches shell: pwsh @@ -94,9 +95,9 @@ jobs: uses: actions/cache/restore@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-1.3.14-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- - name: Restore JDTLS downloads id: jdtls-download-cache diff --git a/scripts/validate-windows-build-caches.ps1 b/scripts/validate-windows-build-caches.ps1 index f044bd23e..83e66da23 100644 --- a/scripts/validate-windows-build-caches.ps1 +++ b/scripts/validate-windows-build-caches.ps1 @@ -68,10 +68,15 @@ $validatorArguments = @( "--cargo-lock", (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") ) if ($IncludeWindowsAssets) { + $package = Get-Content -Raw -LiteralPath (Join-Path $root "windows/tauri/package.json") | ConvertFrom-Json + $bunVersion = ([string]$package.packageManager) -replace '^bun@', '' + if ([string]::IsNullOrWhiteSpace($bunVersion)) { + throw "windows/tauri/package.json must declare packageManager as bun@." + } $validatorArguments += @( "--jdtls-cache", $jdtlsCache, "--jdtls-manifest", (Join-Path $root "third_party/jdtls/manifest.json"), - "--bun-version", "1.3.14", + "--bun-version", $bunVersion, "--bun-lock", (Join-Path $root "windows/tauri/bun.lock"), "--bun-cache", $bunCache ) diff --git a/windows/tauri/package.json b/windows/tauri/package.json index 93ac9b31f..61c5f0109 100644 --- a/windows/tauri/package.json +++ b/windows/tauri/package.json @@ -140,5 +140,5 @@ "engines": { "node": ">=22.0.0" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.3.12" } From 9a7fda17c7b98a377559bc7d587c93792b1c2f20 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 22 Aug 2026 13:48:08 +0800 Subject: [PATCH 4/5] fix(ci): keep Bun cache temp on Windows volume --- .github/workflows/ci-windows.yml | 13 +++- .github/workflows/release-preview-windows.yml | 9 ++- .github/workflows/release-windows.yml | 9 ++- .../install-windows-frontend-dependencies.ps1 | 73 +++++++++++++++---- scripts/test-verify-download-cache.mjs | 31 ++++++++ scripts/validate-windows-build-caches.ps1 | 3 +- 6 files changed, 115 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index b92377442..cdde4f8e7 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -29,6 +29,10 @@ jobs: with: fetch-depth: 0 + - name: Test dependency cache verification + shell: bash + run: node scripts/test-verify-download-cache.mjs + - name: Resolve comparison base id: base env: @@ -112,7 +116,12 @@ jobs: shell: pwsh run: | "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + # Bun's cache and temp directory must share a Windows volume so + # lifecycle shims such as node-gyp.cmd are added to the correct PATH. "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + "BUN_TMPDIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-tmp')" >> $env:GITHUB_ENV + # The verified cache rejects links, so omit Bun's junction-only index. + "BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX=1" >> $env:GITHUB_ENV - name: Resolve Rust cache identity id: rust-cache @@ -163,9 +172,9 @@ jobs: uses: actions/cache@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2- - name: Restore JDTLS downloads if: needs.changes.outputs.windows == 'true' diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml index 06ee18e6a..91a10e512 100644 --- a/.github/workflows/release-preview-windows.yml +++ b/.github/workflows/release-preview-windows.yml @@ -61,7 +61,12 @@ jobs: shell: pwsh run: | "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + # Bun's cache and temp directory must share a Windows volume so + # lifecycle shims such as node-gyp.cmd are added to the correct PATH. "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + "BUN_TMPDIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-tmp')" >> $env:GITHUB_ENV + # The verified cache rejects links, so omit Bun's junction-only index. + "BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX=1" >> $env:GITHUB_ENV - name: Resolve Rust cache identity id: rust-cache @@ -111,9 +116,9 @@ jobs: uses: actions/cache@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2- - name: Restore JDTLS downloads id: jdtls-download-cache diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 6baca7931..5ca50f224 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -45,7 +45,12 @@ jobs: shell: pwsh run: | "CARGO_HOME=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/cargo-home')" >> $env:GITHUB_ENV + # Bun's cache and temp directory must share a Windows volume so + # lifecycle shims such as node-gyp.cmd are added to the correct PATH. "BUN_INSTALL_CACHE_DIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-cache')" >> $env:GITHUB_ENV + "BUN_TMPDIR=$(Join-Path $env:GITHUB_WORKSPACE '.artifacts/bun-tmp')" >> $env:GITHUB_ENV + # The verified cache rejects links, so omit Bun's junction-only index. + "BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX=1" >> $env:GITHUB_ENV - name: Resolve Rust cache identity id: rust-cache @@ -95,9 +100,9 @@ jobs: uses: actions/cache/restore@v6 with: path: .artifacts/bun-cache - key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1-${{ hashFiles('windows/tauri/bun.lock') }} + key: lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2-${{ hashFiles('windows/tauri/bun.lock') }} restore-keys: | - lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v1- + lithe-${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.bun.outputs.bun-version }}-v2- - name: Restore JDTLS downloads id: jdtls-download-cache diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 index e013ea6ee..110c47da7 100644 --- a/scripts/install-windows-frontend-dependencies.ps1 +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -7,19 +7,46 @@ $windowsApp = Join-Path $root "windows/tauri" $package = Get-Content -Raw -LiteralPath (Join-Path $windowsApp "package.json") | ConvertFrom-Json $expectedVersion = ([string]$package.packageManager) -replace '^bun@', '' $bunCache = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-cache")) +$bunTemp = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-tmp")) $nodeModules = [System.IO.Path]::GetFullPath((Join-Path $windowsApp "node_modules")) -$env:BUN_INSTALL_CACHE_DIR = $bunCache function Write-CacheWarning { - param([string]$Message) + param([string]$Message, [string]$Title = "Bun cache fallback") - Write-Warning $Message if ($env:GITHUB_ACTIONS -eq "true") { $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") - Write-Output "::warning title=Bun cache fallback::$escaped" + Write-Output "::warning title=$Title::$escaped" + } else { + Write-Warning "$Title`: $Message" } } +# Bun can expose the original temp path after a cross-volume fallback, while +# creating lifecycle shims such as node-gyp.cmd beside the install cache. +$cacheVolume = [System.IO.Path]::GetPathRoot($bunCache) +$tempVolume = [System.IO.Path]::GetPathRoot($bunTemp) +if (-not $cacheVolume.Equals($tempVolume, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "BUN_INSTALL_CACHE_DIR and BUN_TMPDIR must be on the same Windows volume." +} +if ((Test-Path Env:BUN_INSTALL_CACHE_DIR) -and + -not [string]::Equals($env:BUN_INSTALL_CACHE_DIR, $bunCache, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-CacheWarning "Ignoring an external BUN_INSTALL_CACHE_DIR override; using the verified repository cache: $bunCache" "Bun cache configuration" +} +if ((Test-Path Env:BUN_TMPDIR) -and + -not [string]::Equals($env:BUN_TMPDIR, $bunTemp, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-CacheWarning "Ignoring an external BUN_TMPDIR override; using the same-volume repository temp directory: $bunTemp" "Bun cache configuration" +} +if ((Test-Path Env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX) -and + $env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX -ne "1") { + Write-CacheWarning "Ignoring an external install-index override; the verified Bun cache cannot contain Windows junctions." "Bun cache configuration" +} +$env:BUN_INSTALL_CACHE_DIR = $bunCache +$env:BUN_TMPDIR = $bunTemp +$env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX = "1" + +if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } +New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null + if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { throw "Bun is required to install Windows frontend dependencies." } @@ -29,15 +56,19 @@ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $expectedVersion) { throw "Bun $expectedVersion is required, but $actualVersion is active." } -New-Item -ItemType Directory -Force -Path $bunCache | Out-Null Push-Location $windowsApp try { & bun install --frozen-lockfile if ($LASTEXITCODE -ne 0) { - Write-CacheWarning "The cached Bun install failed. Clearing repository-scoped cache data and retrying with ordinary downloads." + if ($env:LITHE_BUN_CACHE_VERIFIED -eq "true") { + Write-CacheWarning "The verified Bun cache could not complete installation. Clearing it and retrying with ordinary downloads." + } else { + Write-CacheWarning "The initial Bun install failed. Clearing partial data and retrying with ordinary downloads." + } if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } if (Test-Path -LiteralPath $nodeModules) { Remove-Item -Recurse -Force -LiteralPath $nodeModules } - New-Item -ItemType Directory -Force -Path $bunCache | Out-Null + New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null & bun install --frozen-lockfile --no-cache if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed after a clean retry." @@ -46,15 +77,25 @@ try { if ($env:LITHE_BUN_CACHE_VERIFIED -ne "true" -or -not (Test-Path -LiteralPath (Join-Path $bunCache ".lithe-integrity.json") -PathType Leaf)) { - & node (Join-Path $root "scripts/verify-download-cache.mjs") ` - --cargo-cache (Join-Path $root ".artifacts/cargo-home/registry/cache") ` - --cargo-lock (Join-Path $root "rust/Cargo.lock") ` - --cargo-lock (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") ` - --bun-version $expectedVersion ` - --bun-lock (Join-Path $windowsApp "bun.lock") ` - --bun-cache $bunCache ` - --write-bun-manifest - if ($LASTEXITCODE -ne 0) { throw "Could not seal the Bun download cache." } + $cacheSealed = $false + try { + & node (Join-Path $root "scripts/verify-download-cache.mjs") ` + --cargo-cache (Join-Path $root ".artifacts/cargo-home/registry/cache") ` + --cargo-lock (Join-Path $root "rust/Cargo.lock") ` + --cargo-lock (Join-Path $root "windows/tauri/src-tauri/Cargo.lock") ` + --bun-version $expectedVersion ` + --bun-lock (Join-Path $windowsApp "bun.lock") ` + --bun-cache $bunCache ` + --write-bun-manifest + $cacheSealed = $LASTEXITCODE -eq 0 + } catch { + Write-Output $_ + } + if (-not $cacheSealed) { + Write-CacheWarning "The Bun download cache could not be sealed safely. Discarding it and continuing with the installed dependencies." + if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + if ($null -ne $env:GITHUB_ENV) { "LITHE_BUN_CACHE_VERIFIED=false" >> $env:GITHUB_ENV } + } } } finally { Pop-Location diff --git a/scripts/test-verify-download-cache.mjs b/scripts/test-verify-download-cache.mjs index 5db6af4f4..99ac0bbf7 100644 --- a/scripts/test-verify-download-cache.mjs +++ b/scripts/test-verify-download-cache.mjs @@ -8,6 +8,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); const verifier = path.join(scriptDirectory, "verify-download-cache.mjs"); const emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lithe-cache-verifier-")); @@ -22,7 +23,37 @@ function run(command, argumentsList, workingDirectory = testRoot) { return result.stdout.trim(); } +async function assertWindowsBunCacheConfiguration() { + const workflowPaths = [ + ".github/workflows/ci-windows.yml", + ".github/workflows/release-preview-windows.yml", + ".github/workflows/release-windows.yml", + ]; + for (const relativePath of workflowPaths) { + const contents = await fs.readFile(path.join(repositoryRoot, relativePath), "utf8"); + assert.match(contents, /^\s*path: \.artifacts\/bun-cache$/m, `${relativePath} must cache Bun's isolated path`); + assert.match(contents, /BUN_INSTALL_CACHE_DIR=.*\.artifacts\/bun-cache/, `${relativePath} must configure the isolated Bun cache`); + assert.match(contents, /BUN_TMPDIR=.*\.artifacts\/bun-tmp/, `${relativePath} must keep Bun temp files on the cache volume`); + assert.match(contents, /BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX=1/, `${relativePath} must omit Bun's Windows junction index`); + assert.match(contents, /bun-\$\{\{ steps\.bun\.outputs\.bun-version \}\}-v2-/, `${relativePath} must isolate the same-volume Bun cache format`); + } + + const installer = await fs.readFile(path.join(repositoryRoot, "scripts/install-windows-frontend-dependencies.ps1"), "utf8"); + assert.match(installer, /\.artifacts\/bun-cache/, "Windows dependency installation must use the isolated Bun cache"); + assert.match(installer, /\.artifacts\/bun-tmp/, "Windows dependency installation must use a repository temp directory"); + assert.match(installer, /GetPathRoot\(\$bunCache\)/, "Windows dependency installation must resolve the cache volume"); + assert.match(installer, /GetPathRoot\(\$bunTemp\)/, "Windows dependency installation must resolve the temp volume"); + assert.match(installer, /\$env:BUN_TMPDIR = \$bunTemp/, "Windows dependency installation must enforce the same-volume temp directory"); + assert.match(installer, /\$env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX = "1"/, "Windows dependency installation must reject Bun's junction index"); + assert.doesNotMatch(installer, /throw "Could not seal the Bun download cache/, "Bun cache sealing failures must not fail the build"); + + const validator = await fs.readFile(path.join(repositoryRoot, "scripts/validate-windows-build-caches.ps1"), "utf8"); + assert.match(validator, /\$bunCache = Join-Path \$artifactsRoot "bun-cache"/, "Windows cache validation must use the isolated Bun cache"); +} + try { + await assertWindowsBunCacheConfiguration(); + const cargoCache = path.join(testRoot, "cargo-cache", "registry"); const cargoLock = path.join(testRoot, "Cargo.lock"); const crate = path.join(cargoCache, "fixture-1.0.0.crate"); diff --git a/scripts/validate-windows-build-caches.ps1 b/scripts/validate-windows-build-caches.ps1 index 83e66da23..f02db1fe1 100644 --- a/scripts/validate-windows-build-caches.ps1 +++ b/scripts/validate-windows-build-caches.ps1 @@ -18,10 +18,11 @@ $jdtlsCache = Join-Path $artifactsRoot "jdtls-downloads" function Write-CacheWarning { param([string]$Title, [string]$Message) - Write-Warning "$Title`: $Message" if ($env:GITHUB_ACTIONS -eq "true") { $escaped = $Message.Replace("%", "%25").Replace("`r", "%0D").Replace("`n", "%0A") Write-Output "::warning title=$Title::$escaped" + } else { + Write-Warning "$Title`: $Message" } } From 385c9490af4afe4d9d32108c544f68e5674b2f93 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 22 Aug 2026 13:56:00 +0800 Subject: [PATCH 5/5] fix(ci): test cache warnings in Actions mode --- scripts/test-verify-download-cache.mjs | 55 +++++++++++++++++--------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/scripts/test-verify-download-cache.mjs b/scripts/test-verify-download-cache.mjs index 99ac0bbf7..530dd4644 100644 --- a/scripts/test-verify-download-cache.mjs +++ b/scripts/test-verify-download-cache.mjs @@ -12,14 +12,23 @@ const repositoryRoot = path.resolve(scriptDirectory, ".."); const verifier = path.join(scriptDirectory, "verify-download-cache.mjs"); const emptySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; const testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lithe-cache-verifier-")); +const testEnvironment = { ...process.env, GITHUB_ACTIONS: "false" }; -function verify(argumentsList, environment = process.env) { +function verify(argumentsList, environment = testEnvironment) { return spawnSync(process.execPath, [verifier, ...argumentsList], { encoding: "utf8", env: environment }); } +function diagnostics(result) { + return [result.stdout, result.stderr].filter(Boolean).join("\n"); +} + +function assertSucceeded(result) { + assert.equal(result.status, 0, diagnostics(result)); +} + function run(command, argumentsList, workingDirectory = testRoot) { const result = spawnSync(command, argumentsList, { cwd: workingDirectory, encoding: "utf8" }); - assert.equal(result.status, 0, result.stderr || result.stdout); + assertSucceeded(result); return result.stdout.trim(); } @@ -65,14 +74,24 @@ try { await fs.writeFile(crate, ""); let result = verify(["--cargo-cache", cargoCache, "--cargo-lock", cargoLock]); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); assert.equal(await fs.readFile(crate, "utf8"), ""); await fs.writeFile(crate, "corrupted"); result = verify(["--cargo-cache", cargoCache, "--cargo-lock", cargoLock]); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); + await assert.rejects(fs.access(crate)); + assert.match(diagnostics(result), /SHA-256 mismatch/); + + await fs.writeFile(crate, "corrupted in Actions mode"); + result = verify( + ["--cargo-cache", cargoCache, "--cargo-lock", cargoLock], + { ...testEnvironment, GITHUB_ACTIONS: "true" }, + ); + assertSucceeded(result); await assert.rejects(fs.access(crate)); - assert.match(result.stderr, /SHA-256 mismatch/); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /::warning title=Cargo cache entry rejected::.*SHA-256 mismatch/); const jdtlsCache = path.join(testRoot, "jdtls-cache"); const jdtlsManifest = path.join(testRoot, "manifest.json"); @@ -103,10 +122,10 @@ try { "--jdtls-manifest", jdtlsManifest, ]); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); assert.equal(await fs.readFile(jdtlsArchive, "utf8"), ""); await assert.rejects(fs.access(unexpected)); - assert.match(result.stderr, /not referenced by the JDTLS manifest/); + assert.match(diagnostics(result), /not referenced by the JDTLS manifest/); const fakeBin = path.join(testRoot, "bin"); const fakeBun = path.join(fakeBin, "bun"); @@ -118,7 +137,7 @@ try { await fs.mkdir(path.dirname(cachedPackage), { recursive: true }); await fs.writeFile(bunLock, "fixture-lock\n"); await fs.writeFile(cachedPackage, "export default 1;\n"); - const bunEnvironment = { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}` }; + const bunEnvironment = { ...testEnvironment, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}` }; const bunArguments = [ "--cargo-cache", cargoCache, @@ -133,16 +152,16 @@ try { ]; result = verify([...bunArguments, "--write-bun-manifest"], bunEnvironment); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); await fs.access(path.join(bunCache, ".lithe-integrity.json")); result = verify(bunArguments, bunEnvironment); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); assert.match(result.stdout, /Bun download cache verified: 1 file/); await fs.writeFile(cachedPackage, "tampered\n"); result = verify(bunArguments, bunEnvironment); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stderr, /SHA-256 mismatch/); + assertSucceeded(result); + assert.match(diagnostics(result), /SHA-256 mismatch/); await assert.rejects(fs.access(cachedPackage)); const swiftSource = path.join(testRoot, "swift-source"); @@ -185,24 +204,24 @@ try { "6.2", ]; result = verify([...swiftpmArguments, "--write-swiftpm-manifest"]); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); await fs.access(path.join(swiftpmCache, ".lithe-integrity.json")); result = verify(swiftpmArguments); - assert.equal(result.status, 0, result.stderr); + assertSucceeded(result); assert.match(result.stdout, /SwiftPM dependency cache verified: 1 repository/); const changedResolved = JSON.parse(await fs.readFile(swiftResolved, "utf8")); changedResolved.originHash = "changed-fixture"; await fs.writeFile(swiftResolved, JSON.stringify(changedResolved)); result = verify(swiftpmArguments); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stderr, /Package\.resolved SHA-256 changed/); + assertSucceeded(result); + assert.match(diagnostics(result), /Package\.resolved SHA-256 changed/); await fs.access(swiftRepository); await fs.writeFile(path.join(swiftRepository, "tampered"), "tampered\n"); result = verify(swiftpmArguments); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stderr, /SHA-256 mismatch|file set does not match/); + assertSucceeded(result); + assert.match(diagnostics(result), /SHA-256 mismatch|file set does not match/); await assert.rejects(fs.access(swiftRepository)); process.stdout.write("Download cache verifier tests passed.\n");