From e1b403d61fffd5fbe2c5eb863897f651a37a308c Mon Sep 17 00:00:00 2001 From: Daiki Matsudate <20222809+d-date@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:32:29 +0900 Subject: [PATCH 1/3] Spend less of a CI job installing Swift Measured on main (run 39647073752): a Linux cache hit took as long as a download, because 9-12 s went to apt-get (most of it the list refresh) and 18-25 s to extracting the archive; Windows spent 23 s downloading and 70 s in the installer, macOS 33 s downloading. - Linux: the packages needed only to run the toolchain install in the background while the archive is fetched, verified and extracted, from the image's package lists first; curl and gnupg2 still come first. The archive is extracted with pigz when the runner has it. - macOS and Windows: the .pkg and the installer are cached. A cached copy is copied to a private directory and verified there on every run, and only bytes that just passed go into the cache; the source output says cache. - Windows: windows-minimal leaves out the debugger, its Python, the IDE support and the Android platform. --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++ README.md | 11 +++++----- action.yml | 33 ++++++++++++++++++++++++++++-- install-linux.sh | 43 +++++++++++++++++++++++++++++++++++----- install-macos.sh | 29 ++++++++++++++++++++++----- install-windows.ps1 | 41 +++++++++++++++++++++++++++++++------- 6 files changed, 160 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af94bdc..f12ca49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: with: swift-version: ${{ matrix.swift }} windows-toolchain-variant: noasserts + windows-minimal: true - name: Check the +NoAsserts toolchain was selected, and compile env: VERSION: ${{ steps.setup.outputs.version }} @@ -121,6 +122,32 @@ jobs: echo "$FIRST / $SECOND" test -n "$FIRST" && test "$FIRST" = "$SECOND" + # The macOS .pkg and the Windows installer cached by `install`, restored and verified again. + installer-cache-hit: + name: installer cache hit (${{ matrix.os }}, ${{ matrix.swift }}) + needs: install + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + swift: ["6.4.1"] + steps: + - uses: actions/checkout@v7 + - id: setup + uses: ./ + with: + swift-version: ${{ matrix.swift }} + - name: Expect the installer to come from the cache (and be re-verified) + env: + SOURCE: ${{ steps.setup.outputs.source }} + run: | + test "$SOURCE" = cache + swift --version + unsupported-version: runs-on: ubuntu-24.04 steps: diff --git a/README.md b/README.md index d11f864..4593204 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ see [How verification works](#how-verification-works)). It never runs a pre-exis | `swift-sdks` | no | | Cross-compilation Swift SDKs to install after the toolchain: `static-linux`, `wasm`, `android` (comma or space separated). | | `use-preinstalled` | no | `false` | **Unverified opt-in.** If `swift` on `PATH` already reports exactly `swift-version`, run and use it instead of installing. By default no pre-existing binary is ever executed. | | `windows-toolchain-variant` | no | `asserts` | Windows only. `asserts` keeps the installer's default `+Asserts` toolchain; `noasserts` installs the `+NoAsserts` one instead. Use `noasserts` on win-arm64, where the `+Asserts` `swift-package.exe` fails to start ([swiftlang/swift#92379](https://github.com/swiftlang/swift/issues/92379)). | -| `cache` | no | `true` | Cache the downloaded Linux toolchain archive and its `.sig` with `actions/cache`. The archive is re-verified with GPG on every run and extracted into a clean per-version directory; `false` reuses nothing on disk. | +| `windows-minimal` | no | `false` | Windows only. `true` leaves out what a CI build does not use: the debugger (`OptionsInstallDBG`), its embedded Python (`OptionsInstallPy`), the IDE support (`OptionsInstallIDE`) and the Android platform (`OptionsInstallAndroidPlatform`). | +| `cache` | no | `true` | Cache the downloaded Linux toolchain archive and its `.sig`, the macOS `.pkg` and the Windows installer with `actions/cache`. A cached copy is copied to a private directory and re-verified (GPG, pkg signature and notarization, Authenticode) on every run before anything runs it; `false` reuses nothing on disk. | ## Outputs @@ -28,7 +29,7 @@ see [How verification works](#how-verification-works)). It never runs a pre-exis | --- | --- | | `version` | Version reported by `swift --version`. | | `swift-path` | Directory containing the active `swift` binary. | -| `source` | `preinstalled` (opt-in only), `cache`, `download` (Linux), `pkg` (macOS) or `installer` (Windows). | +| `source` | `preinstalled` (opt-in only), `cache` (a cached archive, `.pkg` or installer, re-verified), `download` (Linux), `pkg` (macOS) or `installer` (Windows). | | `sdks` | `swift sdk list` output after installing `swift-sdks`. | The action fails if `swift --version` does not report exactly the requested version afterwards. @@ -60,15 +61,15 @@ A cache entry that fails verification fails the job instead of being re-download `RUNNER_TEMP` is private to one runner instance and emptied for every job on GitHub-hosted runners, so earlier or concurrent jobs cannot plant or wipe the toolchain. Self-hosted runners must not share `RUNNER_TEMP` between concurrently running jobs, and must run jobs of different trust levels on separate runner instances. Missing build prerequisites (`libcurl4-openssl-dev`, -`libxml2-dev`, the GCC dev packages, ...) are installed with `apt-get` only if absent. +`libxml2-dev`, the GCC dev packages, ...) are installed with `apt-get` only if absent - from the package lists the runner image ships, refreshed only if that fails, and, apart from `curl` and `gnupg2`, in the background while the archive is fetched, verified and extracted. The archive is extracted with `pigz` when the runner has it. macOS: the package is installed into the user domain (`~/Library/Developer/Toolchains`), falling back to `sudo installer -target /`. `TOOLCHAINS` is exported with the toolchain's bundle identifier and its -`usr/bin` is prepended to `PATH`. The install path is deterministic (like the Linux and Windows ones), so SwiftPM build caches stay valid. macOS installs are not cached. +`usr/bin` is prepended to `PATH`. The install path is deterministic (like the Linux and Windows ones), so SwiftPM build caches stay valid. The macOS `.pkg` is cached the same way: restored, copied to a private directory, and installed only after its signature and notarization check pass again. Windows: the installer runs silently (`/install /quiet`) into `%LocalAppData%\Programs\Swift`; the toolchain and runtime `bin` directories are added to `PATH` and `SDKROOT` is set. Visual Studio (C++ tools, Windows SDK), -preinstalled on GitHub's Windows runners, is still required. Windows installs are not cached. The directories for the requested version must not exist before the +preinstalled on GitHub's Windows runners, is still required. The installer is cached like the macOS `.pkg` and passes the Authenticode check again on every run. The directories for the requested version must not exist before the installer runs, and exactly one matching `Toolchains`, `Runtimes` and `Platforms` directory is selected. With `windows-toolchain-variant: noasserts` the installer is passed `OptionsInstallAssertsToolchain=0 OptionsInstallNoAssertsToolchain=1`, and the toolchain directory selected must be the `+NoAsserts` one. diff --git a/action.yml b/action.yml index fe46e25..185d88c 100644 --- a/action.yml +++ b/action.yml @@ -26,10 +26,18 @@ inputs: (https://github.com/swiftlang/swift/issues/92379); the +NoAsserts one does not. required: false default: "asserts" + windows-minimal: + description: >- + Windows only: leave out what a CI build does not use - the debugger, its embedded Python, the IDE + support (SourceKit-LSP) and the Android platform. Shortens the install; default false installs + everything the installer installs by default. + required: false + default: "false" cache: description: >- - Cache the downloaded Linux toolchain archive and its signature with actions/cache. The archive is - re-verified with GPG on every run; with false nothing on disk is ever reused. + Cache the downloaded Linux toolchain archive and its signature, the macOS .pkg and the Windows + installer with actions/cache. A cached copy is re-verified (GPG, pkg signature, Authenticode) + on every run; with false nothing on disk is ever reused. required: false default: "true" outputs: @@ -96,12 +104,31 @@ runs: DOWNLOAD_DIR: ${{ inputs.cache == 'true' && steps.plan.outputs.download-dir || '' }} run: bash "$GITHUB_ACTION_PATH/install-linux.sh" + # The macOS .pkg and the Windows installer are cached as they were downloaded. Like the Linux + # archive, a cached copy is copied to a private directory and its signature checked again on every + # run before anything runs it. + - name: Plan installer cache + id: installer-cache + if: steps.existing.outputs.found != 'true' && runner.os != 'Linux' && inputs.cache == 'true' + shell: bash + env: + SWIFT_VERSION: ${{ inputs.swift-version }} + run: echo "dir=$RUNNER_TOOL_CACHE/swift-installers/$SWIFT_VERSION-$RUNNER_OS-$RUNNER_ARCH" >> "$GITHUB_OUTPUT" + + - name: Restore Swift installer cache + if: steps.existing.outputs.found != 'true' && runner.os != 'Linux' && inputs.cache == 'true' + uses: actions/cache@v6 + with: + path: ${{ steps.installer-cache.outputs.dir }} + key: swift-installer-${{ inputs.swift-version }}-${{ runner.os }}-${{ runner.arch }}-v1 + - name: Install Swift (macOS, signature verified) id: macos if: steps.existing.outputs.found != 'true' && runner.os == 'macOS' shell: bash env: SWIFT_VERSION: ${{ inputs.swift-version }} + INSTALLER_CACHE_DIR: ${{ steps.installer-cache.outputs.dir }} run: bash "$GITHUB_ACTION_PATH/install-macos.sh" - name: Install Swift (Windows, Authenticode verified) @@ -111,6 +138,8 @@ runs: env: SWIFT_VERSION: ${{ inputs.swift-version }} WINDOWS_TOOLCHAIN_VARIANT: ${{ inputs.windows-toolchain-variant }} + WINDOWS_MINIMAL: ${{ inputs.windows-minimal }} + INSTALLER_CACHE_DIR: ${{ steps.installer-cache.outputs.dir }} run: '& "$env:GITHUB_ACTION_PATH/install-windows.ps1"' - name: Report Swift version diff --git a/install-linux.sh b/install-linux.sh index 55c9d5a..e540fdb 100755 --- a/install-linux.sh +++ b/install-linux.sh @@ -31,15 +31,37 @@ for pkg in curl gnupg2 binutils git libcurl4-openssl-dev libedit2 libsqlite3-0 l libncurses-dev pkg-config tzdata uuid-dev zlib1g-dev "libgcc-${gcc_ver}-dev" "libstdc++-${gcc_ver}-dev"; do dpkg -s "$pkg" > /dev/null 2>&1 || missing+=("$pkg") done -if [ "${#missing[@]}" -gt 0 ]; then - echo "Installing missing packages: ${missing[*]}" - "${sudo_cmd[@]}" apt-get update -qq - "${sudo_cmd[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "${missing[@]}" +# The package lists a runner image ships are tried first, and refreshed only when that fails: the +# refresh is most of what installing a couple of packages costs. +apt_install() { + "${sudo_cmd[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$@" \ + || { "${sudo_cmd[@]}" apt-get update -qq \ + && "${sudo_cmd[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$@"; } +} + +# curl and gnupg2 download and verify the toolchain, so they have to be there first. Everything else +# is needed only to run it, and is installed in the background while the archive is fetched, +# verified and extracted, then waited for at the end. +needed_now=() +later=() +for pkg in "${missing[@]}"; do + case "$pkg" in curl | gnupg2) needed_now+=("$pkg") ;; *) later+=("$pkg") ;; esac +done +if [ "${#needed_now[@]}" -gt 0 ]; then + echo "Installing missing packages: ${needed_now[*]}" + apt_install "${needed_now[@]}" fi scratch="$(mktemp -d "${RUNNER_TEMP:-/tmp}/setup-swift.XXXXXX")" trap 'rm -r -f "$scratch"' EXIT +apt_pid="" +if [ "${#later[@]}" -gt 0 ]; then + echo "Installing missing packages in the background: ${later[*]}" + apt_install "${later[@]}" > "$scratch/apt.log" 2>&1 & + apt_pid=$! +fi + download() { # url dest curl --fail --silent --show-error --location --retry 5 --retry-delay 5 --retry-all-errors \ --connect-timeout 30 --output "$2" "$1" \ @@ -107,9 +129,20 @@ assert_no_symlinks "$dest" rm -r -f -- "$dest" mkdir "$dest" assert_no_symlinks "$dest" -tar -xzf "$snap/$file" -C "$dest" --strip-components=1 --no-same-owner +# pigz where the runner has it: inflation stays single-threaded, but pigz reads, writes and +# checksums on threads of its own. +if command -v pigz > /dev/null; then + tar -I pigz -xf "$snap/$file" -C "$dest" --strip-components=1 --no-same-owner +else + tar -xzf "$snap/$file" -C "$dest" --strip-components=1 --no-same-owner +fi rm -r -f -- "$snap" [ -x "$dest/usr/bin/swift" ] || { echo "::error::$dest/usr/bin/swift is missing after extraction"; exit 1; } +if [ -n "$apt_pid" ] && ! wait "$apt_pid"; then + cat "$scratch/apt.log" + echo "::error::Installing the packages the toolchain needs failed: ${later[*]}" + exit 1 +fi if [ -n "${GITHUB_PATH:-}" ]; then echo "$dest/usr/bin" >> "$GITHUB_PATH"; fi if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "source=$source" >> "$GITHUB_OUTPUT"; fi echo "Installed Swift toolchain at $dest" diff --git a/install-macos.sh b/install-macos.sh index 0d9a74e..bba9a87 100755 --- a/install-macos.sh +++ b/install-macos.sh @@ -14,10 +14,21 @@ trap 'rm -r -f "$work"' EXIT pkg="$work/swift-${v}-RELEASE-osx.pkg" url="https://download.swift.org/swift-${v}-release/xcode/swift-${v}-RELEASE/swift-${v}-RELEASE-osx.pkg" -echo "Downloading $url" -curl --fail --silent --show-error --location --retry 5 --retry-delay 5 --retry-all-errors \ - --connect-timeout 30 --output "$pkg" "$url" \ - || { echo "::error::Failed to download $url"; exit 1; } +# A cached .pkg is copied into this run's private directory and checked there, like a download: the +# cache directory can be rewritten between a check and a use, the private copy cannot. +cached="" +[ -z "${INSTALLER_CACHE_DIR:-}" ] || cached="$INSTALLER_CACHE_DIR/$(basename "$pkg")" +from_cache="" +if [ -n "$cached" ] && [ -f "$cached" ]; then + echo "Found cached package $cached; it will be re-verified" + cp "$cached" "$pkg" + from_cache=1 +else + echo "Downloading $url" + curl --fail --silent --show-error --location --retry 5 --retry-delay 5 --retry-all-errors \ + --connect-timeout 30 --output "$pkg" "$url" \ + || { echo "::error::Failed to download $url"; exit 1; } +fi sig="$(pkgutil --check-signature "$pkg")" || { echo "$sig"; echo "::error::pkgutil signature check failed"; exit 1; } echo "$sig" @@ -25,6 +36,12 @@ grep -qF "1. $EXPECTED_SIGNER" <<< "$sig" \ || { echo "::error::Package is not signed by '$EXPECTED_SIGNER'"; exit 1; } grep -qF "Notarization: trusted by the Apple notary service" <<< "$sig" \ || { echo "::error::Package is not notarized"; exit 1; } +# Only bytes that just passed the check go into the cache; actions/cache saves the directory after +# the job. A cached copy that fails the check fails the job rather than being replaced. +if [ -n "$cached" ] && [ -z "$from_cache" ]; then + mkdir -p "$INSTALLER_CACHE_DIR" + cp "$pkg" "$cached" +fi if [ -n "${VERIFY_ONLY:-}" ]; then echo "VERIFY_ONLY set: signature OK, not installing"; exit 0; fi # Prefer the per-user domain (no root needed); fall back to the system domain. @@ -39,6 +56,8 @@ tc="$tc_root/swift-${v}-RELEASE.xctoolchain" [ -d "$tc" ] || { echo "::error::Expected toolchain not found at $tc"; exit 1; } id="$(plutil -extract CFBundleIdentifier raw -o - "$tc/Info.plist")" echo "Installed $tc ($id)" -if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "source=pkg" >> "$GITHUB_OUTPUT"; fi +source=pkg +[ -z "$from_cache" ] || source=cache +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "source=$source" >> "$GITHUB_OUTPUT"; fi if [ -n "${GITHUB_ENV:-}" ]; then echo "TOOLCHAINS=$id" >> "$GITHUB_ENV"; fi if [ -n "${GITHUB_PATH:-}" ]; then echo "$tc/usr/bin" >> "$GITHUB_PATH"; fi diff --git a/install-windows.ps1 b/install-windows.ps1 index 207829f..ca6438d 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -17,6 +17,16 @@ switch ($variant) { } default { Write-Host "::error::windows-toolchain-variant must be asserts or noasserts, got '$variant'"; exit 1 } } +# windows-minimal: the installer features a CI build does not use. The debugger, its Python and the IDE +# support are the bulk of what the installer writes besides the toolchain, and the Android platform +# is a second SDK. +switch ($env:WINDOWS_MINIMAL) { + { $_ -in @('', 'false') } { } + 'true' { + $installerOptions += @('OptionsInstallDBG=0', 'OptionsInstallPy=0', 'OptionsInstallIDE=0', 'OptionsInstallAndroidPlatform=0') + } + default { Write-Host "::error::windows-minimal must be true or false, got '$env:WINDOWS_MINIMAL'"; exit 1 } +} # Publisher the installer must be signed by, see README.md. swift.org's Windows installers are signed # with an Apple Inc. certificate whose OU (an Apple internal group id) is deliberately not pinned. $ExpectedSubject = '^CN=Apple Inc\., (OU=[^,]+, )?O=Apple Inc\., L=Cupertino, S=California, C=US$' @@ -28,13 +38,22 @@ $work = Join-Path ($env:RUNNER_TEMP ?? $env:TEMP) "setup-swift-$([guid]::NewGuid New-Item -ItemType Directory -Path $work | Out-Null $exe = Join-Path $work $file -Write-Host "Downloading $url" -$ok = $false -foreach ($i in 1..5) { - try { Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing; $ok = $true; break } - catch { Write-Host "Attempt $i failed: $($_.Exception.Message)"; Start-Sleep -Seconds 5 } +# A cached installer is copied into this run's private directory and verified there, like a download: +# the cache directory can be rewritten between a check and a use, the private copy cannot. +$cached = if ($env:INSTALLER_CACHE_DIR) { Join-Path $env:INSTALLER_CACHE_DIR $file } else { $null } +$fromCache = $cached -and (Test-Path -LiteralPath $cached -PathType Leaf) +if ($fromCache) { + Write-Host "Found cached installer $cached; it will be re-verified" + Copy-Item -LiteralPath $cached -Destination $exe +} else { + Write-Host "Downloading $url" + $ok = $false + foreach ($i in 1..5) { + try { Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing; $ok = $true; break } + catch { Write-Host "Attempt $i failed: $($_.Exception.Message)"; Start-Sleep -Seconds 5 } + } + if (-not $ok) { Write-Host "::error::Failed to download $url"; exit 1 } } -if (-not $ok) { Write-Host "::error::Failed to download $url"; exit 1 } $sig = Get-AuthenticodeSignature -FilePath $exe Write-Host "Authenticode status: $($sig.Status); signer: $($sig.SignerCertificate.Subject)" @@ -46,6 +65,14 @@ if ($sig.SignerCertificate.Subject -notmatch $ExpectedSubject) { } Write-Host "Authenticode signature verified for $file" +# Only bytes that just passed the check go into the cache; actions/cache saves the directory after +# the job. A failed check leaves the cache as it was, so a tampered entry fails every run that +# restores it rather than being replaced by a fresh download. +if ($cached -and -not $fromCache) { + New-Item -ItemType Directory -Path $env:INSTALLER_CACHE_DIR -Force | Out-Null + Copy-Item -LiteralPath $exe -Destination $cached +} + # swift.org's installer defaults to %LocalAppData%\Programs\Swift. Directories for the requested # version must not exist beforehand, so only what this verified installer wrote is selected. $root = Join-Path $env:LOCALAPPDATA 'Programs\Swift' @@ -89,5 +116,5 @@ foreach ($d in @($toolchainBin, $runtimeBin, $sdkRoot)) { Add-Content -Path $env:GITHUB_PATH -Value $toolchainBin Add-Content -Path $env:GITHUB_PATH -Value $runtimeBin Add-Content -Path $env:GITHUB_ENV -Value "SDKROOT=$sdkRoot" -Add-Content -Path $env:GITHUB_OUTPUT -Value 'source=installer' +Add-Content -Path $env:GITHUB_OUTPUT -Value $(if ($fromCache) { 'source=cache' } else { 'source=installer' }) Remove-Item -Recurse -Force $work From 7152de0a27bee1814916c7e2e19cd02895b40af2 Mon Sep 17 00:00:00 2001 From: Daiki Matsudate <20222809+d-date@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:58:41 +0900 Subject: [PATCH 2/3] Download the macOS .pkg and the Windows installer again rather than cache them Measured on this PR's run: restoring them from the Actions cache took 22-35 s, against 23-33 s downloading them from swift.org, and on Windows the copy and Authenticode check of the restored installer added 28 s more. --- .github/workflows/ci.yml | 26 -------------------------- README.md | 8 ++++---- action.yml | 27 ++++----------------------- install-macos.sh | 29 +++++------------------------ install-windows.ps1 | 31 +++++++------------------------ 5 files changed, 20 insertions(+), 101 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f12ca49..8c0ace4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,32 +122,6 @@ jobs: echo "$FIRST / $SECOND" test -n "$FIRST" && test "$FIRST" = "$SECOND" - # The macOS .pkg and the Windows installer cached by `install`, restored and verified again. - installer-cache-hit: - name: installer cache hit (${{ matrix.os }}, ${{ matrix.swift }}) - needs: install - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest] - swift: ["6.4.1"] - steps: - - uses: actions/checkout@v7 - - id: setup - uses: ./ - with: - swift-version: ${{ matrix.swift }} - - name: Expect the installer to come from the cache (and be re-verified) - env: - SOURCE: ${{ steps.setup.outputs.source }} - run: | - test "$SOURCE" = cache - swift --version - unsupported-version: runs-on: ubuntu-24.04 steps: diff --git a/README.md b/README.md index 4593204..4c38499 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ see [How verification works](#how-verification-works)). It never runs a pre-exis | `use-preinstalled` | no | `false` | **Unverified opt-in.** If `swift` on `PATH` already reports exactly `swift-version`, run and use it instead of installing. By default no pre-existing binary is ever executed. | | `windows-toolchain-variant` | no | `asserts` | Windows only. `asserts` keeps the installer's default `+Asserts` toolchain; `noasserts` installs the `+NoAsserts` one instead. Use `noasserts` on win-arm64, where the `+Asserts` `swift-package.exe` fails to start ([swiftlang/swift#92379](https://github.com/swiftlang/swift/issues/92379)). | | `windows-minimal` | no | `false` | Windows only. `true` leaves out what a CI build does not use: the debugger (`OptionsInstallDBG`), its embedded Python (`OptionsInstallPy`), the IDE support (`OptionsInstallIDE`) and the Android platform (`OptionsInstallAndroidPlatform`). | -| `cache` | no | `true` | Cache the downloaded Linux toolchain archive and its `.sig`, the macOS `.pkg` and the Windows installer with `actions/cache`. A cached copy is copied to a private directory and re-verified (GPG, pkg signature and notarization, Authenticode) on every run before anything runs it; `false` reuses nothing on disk. | +| `cache` | no | `true` | Cache the downloaded Linux toolchain archive and its `.sig` with `actions/cache`. The archive is re-verified with GPG on every run and extracted into a clean per-version directory; `false` reuses nothing on disk. The macOS `.pkg` and the Windows installer are not cached: restoring them from the Actions cache measured slower than downloading them from swift.org. | ## Outputs @@ -29,7 +29,7 @@ see [How verification works](#how-verification-works)). It never runs a pre-exis | --- | --- | | `version` | Version reported by `swift --version`. | | `swift-path` | Directory containing the active `swift` binary. | -| `source` | `preinstalled` (opt-in only), `cache` (a cached archive, `.pkg` or installer, re-verified), `download` (Linux), `pkg` (macOS) or `installer` (Windows). | +| `source` | `preinstalled` (opt-in only), `cache`, `download` (Linux), `pkg` (macOS) or `installer` (Windows). | | `sdks` | `swift sdk list` output after installing `swift-sdks`. | The action fails if `swift --version` does not report exactly the requested version afterwards. @@ -65,11 +65,11 @@ concurrently running jobs, and must run jobs of different trust levels on separa macOS: the package is installed into the user domain (`~/Library/Developer/Toolchains`), falling back to `sudo installer -target /`. `TOOLCHAINS` is exported with the toolchain's bundle identifier and its -`usr/bin` is prepended to `PATH`. The install path is deterministic (like the Linux and Windows ones), so SwiftPM build caches stay valid. The macOS `.pkg` is cached the same way: restored, copied to a private directory, and installed only after its signature and notarization check pass again. +`usr/bin` is prepended to `PATH`. The install path is deterministic (like the Linux and Windows ones), so SwiftPM build caches stay valid. macOS installs are not cached. Windows: the installer runs silently (`/install /quiet`) into `%LocalAppData%\Programs\Swift`; the toolchain and runtime `bin` directories are added to `PATH` and `SDKROOT` is set. Visual Studio (C++ tools, Windows SDK), -preinstalled on GitHub's Windows runners, is still required. The installer is cached like the macOS `.pkg` and passes the Authenticode check again on every run. The directories for the requested version must not exist before the +preinstalled on GitHub's Windows runners, is still required. Windows installs are not cached. The directories for the requested version must not exist before the installer runs, and exactly one matching `Toolchains`, `Runtimes` and `Platforms` directory is selected. With `windows-toolchain-variant: noasserts` the installer is passed `OptionsInstallAssertsToolchain=0 OptionsInstallNoAssertsToolchain=1`, and the toolchain directory selected must be the `+NoAsserts` one. diff --git a/action.yml b/action.yml index 185d88c..4f71159 100644 --- a/action.yml +++ b/action.yml @@ -35,9 +35,10 @@ inputs: default: "false" cache: description: >- - Cache the downloaded Linux toolchain archive and its signature, the macOS .pkg and the Windows - installer with actions/cache. A cached copy is re-verified (GPG, pkg signature, Authenticode) - on every run; with false nothing on disk is ever reused. + Cache the downloaded Linux toolchain archive and its signature with actions/cache. The archive is + re-verified with GPG on every run; with false nothing on disk is ever reused. The macOS .pkg and + the Windows installer are not cached: restoring them from the Actions cache measured slower than + downloading them from swift.org. required: false default: "true" outputs: @@ -104,31 +105,12 @@ runs: DOWNLOAD_DIR: ${{ inputs.cache == 'true' && steps.plan.outputs.download-dir || '' }} run: bash "$GITHUB_ACTION_PATH/install-linux.sh" - # The macOS .pkg and the Windows installer are cached as they were downloaded. Like the Linux - # archive, a cached copy is copied to a private directory and its signature checked again on every - # run before anything runs it. - - name: Plan installer cache - id: installer-cache - if: steps.existing.outputs.found != 'true' && runner.os != 'Linux' && inputs.cache == 'true' - shell: bash - env: - SWIFT_VERSION: ${{ inputs.swift-version }} - run: echo "dir=$RUNNER_TOOL_CACHE/swift-installers/$SWIFT_VERSION-$RUNNER_OS-$RUNNER_ARCH" >> "$GITHUB_OUTPUT" - - - name: Restore Swift installer cache - if: steps.existing.outputs.found != 'true' && runner.os != 'Linux' && inputs.cache == 'true' - uses: actions/cache@v6 - with: - path: ${{ steps.installer-cache.outputs.dir }} - key: swift-installer-${{ inputs.swift-version }}-${{ runner.os }}-${{ runner.arch }}-v1 - - name: Install Swift (macOS, signature verified) id: macos if: steps.existing.outputs.found != 'true' && runner.os == 'macOS' shell: bash env: SWIFT_VERSION: ${{ inputs.swift-version }} - INSTALLER_CACHE_DIR: ${{ steps.installer-cache.outputs.dir }} run: bash "$GITHUB_ACTION_PATH/install-macos.sh" - name: Install Swift (Windows, Authenticode verified) @@ -139,7 +121,6 @@ runs: SWIFT_VERSION: ${{ inputs.swift-version }} WINDOWS_TOOLCHAIN_VARIANT: ${{ inputs.windows-toolchain-variant }} WINDOWS_MINIMAL: ${{ inputs.windows-minimal }} - INSTALLER_CACHE_DIR: ${{ steps.installer-cache.outputs.dir }} run: '& "$env:GITHUB_ACTION_PATH/install-windows.ps1"' - name: Report Swift version diff --git a/install-macos.sh b/install-macos.sh index bba9a87..0d9a74e 100755 --- a/install-macos.sh +++ b/install-macos.sh @@ -14,21 +14,10 @@ trap 'rm -r -f "$work"' EXIT pkg="$work/swift-${v}-RELEASE-osx.pkg" url="https://download.swift.org/swift-${v}-release/xcode/swift-${v}-RELEASE/swift-${v}-RELEASE-osx.pkg" -# A cached .pkg is copied into this run's private directory and checked there, like a download: the -# cache directory can be rewritten between a check and a use, the private copy cannot. -cached="" -[ -z "${INSTALLER_CACHE_DIR:-}" ] || cached="$INSTALLER_CACHE_DIR/$(basename "$pkg")" -from_cache="" -if [ -n "$cached" ] && [ -f "$cached" ]; then - echo "Found cached package $cached; it will be re-verified" - cp "$cached" "$pkg" - from_cache=1 -else - echo "Downloading $url" - curl --fail --silent --show-error --location --retry 5 --retry-delay 5 --retry-all-errors \ - --connect-timeout 30 --output "$pkg" "$url" \ - || { echo "::error::Failed to download $url"; exit 1; } -fi +echo "Downloading $url" +curl --fail --silent --show-error --location --retry 5 --retry-delay 5 --retry-all-errors \ + --connect-timeout 30 --output "$pkg" "$url" \ + || { echo "::error::Failed to download $url"; exit 1; } sig="$(pkgutil --check-signature "$pkg")" || { echo "$sig"; echo "::error::pkgutil signature check failed"; exit 1; } echo "$sig" @@ -36,12 +25,6 @@ grep -qF "1. $EXPECTED_SIGNER" <<< "$sig" \ || { echo "::error::Package is not signed by '$EXPECTED_SIGNER'"; exit 1; } grep -qF "Notarization: trusted by the Apple notary service" <<< "$sig" \ || { echo "::error::Package is not notarized"; exit 1; } -# Only bytes that just passed the check go into the cache; actions/cache saves the directory after -# the job. A cached copy that fails the check fails the job rather than being replaced. -if [ -n "$cached" ] && [ -z "$from_cache" ]; then - mkdir -p "$INSTALLER_CACHE_DIR" - cp "$pkg" "$cached" -fi if [ -n "${VERIFY_ONLY:-}" ]; then echo "VERIFY_ONLY set: signature OK, not installing"; exit 0; fi # Prefer the per-user domain (no root needed); fall back to the system domain. @@ -56,8 +39,6 @@ tc="$tc_root/swift-${v}-RELEASE.xctoolchain" [ -d "$tc" ] || { echo "::error::Expected toolchain not found at $tc"; exit 1; } id="$(plutil -extract CFBundleIdentifier raw -o - "$tc/Info.plist")" echo "Installed $tc ($id)" -source=pkg -[ -z "$from_cache" ] || source=cache -if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "source=$source" >> "$GITHUB_OUTPUT"; fi +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "source=pkg" >> "$GITHUB_OUTPUT"; fi if [ -n "${GITHUB_ENV:-}" ]; then echo "TOOLCHAINS=$id" >> "$GITHUB_ENV"; fi if [ -n "${GITHUB_PATH:-}" ]; then echo "$tc/usr/bin" >> "$GITHUB_PATH"; fi diff --git a/install-windows.ps1 b/install-windows.ps1 index ca6438d..4dc3564 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -38,22 +38,13 @@ $work = Join-Path ($env:RUNNER_TEMP ?? $env:TEMP) "setup-swift-$([guid]::NewGuid New-Item -ItemType Directory -Path $work | Out-Null $exe = Join-Path $work $file -# A cached installer is copied into this run's private directory and verified there, like a download: -# the cache directory can be rewritten between a check and a use, the private copy cannot. -$cached = if ($env:INSTALLER_CACHE_DIR) { Join-Path $env:INSTALLER_CACHE_DIR $file } else { $null } -$fromCache = $cached -and (Test-Path -LiteralPath $cached -PathType Leaf) -if ($fromCache) { - Write-Host "Found cached installer $cached; it will be re-verified" - Copy-Item -LiteralPath $cached -Destination $exe -} else { - Write-Host "Downloading $url" - $ok = $false - foreach ($i in 1..5) { - try { Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing; $ok = $true; break } - catch { Write-Host "Attempt $i failed: $($_.Exception.Message)"; Start-Sleep -Seconds 5 } - } - if (-not $ok) { Write-Host "::error::Failed to download $url"; exit 1 } +Write-Host "Downloading $url" +$ok = $false +foreach ($i in 1..5) { + try { Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing; $ok = $true; break } + catch { Write-Host "Attempt $i failed: $($_.Exception.Message)"; Start-Sleep -Seconds 5 } } +if (-not $ok) { Write-Host "::error::Failed to download $url"; exit 1 } $sig = Get-AuthenticodeSignature -FilePath $exe Write-Host "Authenticode status: $($sig.Status); signer: $($sig.SignerCertificate.Subject)" @@ -65,14 +56,6 @@ if ($sig.SignerCertificate.Subject -notmatch $ExpectedSubject) { } Write-Host "Authenticode signature verified for $file" -# Only bytes that just passed the check go into the cache; actions/cache saves the directory after -# the job. A failed check leaves the cache as it was, so a tampered entry fails every run that -# restores it rather than being replaced by a fresh download. -if ($cached -and -not $fromCache) { - New-Item -ItemType Directory -Path $env:INSTALLER_CACHE_DIR -Force | Out-Null - Copy-Item -LiteralPath $exe -Destination $cached -} - # swift.org's installer defaults to %LocalAppData%\Programs\Swift. Directories for the requested # version must not exist beforehand, so only what this verified installer wrote is selected. $root = Join-Path $env:LOCALAPPDATA 'Programs\Swift' @@ -116,5 +99,5 @@ foreach ($d in @($toolchainBin, $runtimeBin, $sdkRoot)) { Add-Content -Path $env:GITHUB_PATH -Value $toolchainBin Add-Content -Path $env:GITHUB_PATH -Value $runtimeBin Add-Content -Path $env:GITHUB_ENV -Value "SDKROOT=$sdkRoot" -Add-Content -Path $env:GITHUB_OUTPUT -Value $(if ($fromCache) { 'source=cache' } else { 'source=installer' }) +Add-Content -Path $env:GITHUB_OUTPUT -Value 'source=installer' Remove-Item -Recurse -Force $work From a840b86dd5cf710d5855a0f69debb07d65dea267 Mon Sep 17 00:00:00 2001 From: Daiki Matsudate <20222809+d-date@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:04:32 +0900 Subject: [PATCH 3/3] Keep the IDE feature in windows-minimal; swift.exe needs it Left out on its own, each feature was measured on 6.4.1: without the IDE feature swift.exe fails with ERROR_SXS_CANT_GEN_ACTCTX (14001), and without the debugger, its Python or the Android platform hello world still builds. Those three cut the installer from 71-83 s to about 57 s. --- README.md | 2 +- action.yml | 6 +++--- install-windows.ps1 | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4c38499..4620ec7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ see [How verification works](#how-verification-works)). It never runs a pre-exis | `swift-sdks` | no | | Cross-compilation Swift SDKs to install after the toolchain: `static-linux`, `wasm`, `android` (comma or space separated). | | `use-preinstalled` | no | `false` | **Unverified opt-in.** If `swift` on `PATH` already reports exactly `swift-version`, run and use it instead of installing. By default no pre-existing binary is ever executed. | | `windows-toolchain-variant` | no | `asserts` | Windows only. `asserts` keeps the installer's default `+Asserts` toolchain; `noasserts` installs the `+NoAsserts` one instead. Use `noasserts` on win-arm64, where the `+Asserts` `swift-package.exe` fails to start ([swiftlang/swift#92379](https://github.com/swiftlang/swift/issues/92379)). | -| `windows-minimal` | no | `false` | Windows only. `true` leaves out what a CI build does not use: the debugger (`OptionsInstallDBG`), its embedded Python (`OptionsInstallPy`), the IDE support (`OptionsInstallIDE`) and the Android platform (`OptionsInstallAndroidPlatform`). | +| `windows-minimal` | no | `false` | Windows only. `true` leaves out what a CI build does not use: the debugger (`OptionsInstallDBG`), its embedded Python (`OptionsInstallPy`) and the Android platform (`OptionsInstallAndroidPlatform`). The IDE feature stays: without it `swift.exe` fails to start (`ERROR_SXS_CANT_GEN_ACTCTX`). | | `cache` | no | `true` | Cache the downloaded Linux toolchain archive and its `.sig` with `actions/cache`. The archive is re-verified with GPG on every run and extracted into a clean per-version directory; `false` reuses nothing on disk. The macOS `.pkg` and the Windows installer are not cached: restoring them from the Actions cache measured slower than downloading them from swift.org. | ## Outputs diff --git a/action.yml b/action.yml index 4f71159..4fe4eb6 100644 --- a/action.yml +++ b/action.yml @@ -28,9 +28,9 @@ inputs: default: "asserts" windows-minimal: description: >- - Windows only: leave out what a CI build does not use - the debugger, its embedded Python, the IDE - support (SourceKit-LSP) and the Android platform. Shortens the install; default false installs - everything the installer installs by default. + Windows only: leave out what a CI build does not use - the debugger, its embedded Python and the + Android platform. Shortens the install; default false installs everything the installer + installs by default. required: false default: "false" cache: diff --git a/install-windows.ps1 b/install-windows.ps1 index 4dc3564..67d5f2d 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -17,13 +17,13 @@ switch ($variant) { } default { Write-Host "::error::windows-toolchain-variant must be asserts or noasserts, got '$variant'"; exit 1 } } -# windows-minimal: the installer features a CI build does not use. The debugger, its Python and the IDE -# support are the bulk of what the installer writes besides the toolchain, and the Android platform -# is a second SDK. +# windows-minimal: the installer features a CI build does not use - the debugger, its Python and the +# Android platform, a second SDK. Not the IDE feature: without it swift.exe fails to start with +# ERROR_SXS_CANT_GEN_ACTCTX (14001), measured on 6.4.1 with each feature left out on its own. switch ($env:WINDOWS_MINIMAL) { { $_ -in @('', 'false') } { } 'true' { - $installerOptions += @('OptionsInstallDBG=0', 'OptionsInstallPy=0', 'OptionsInstallIDE=0', 'OptionsInstallAndroidPlatform=0') + $installerOptions += @('OptionsInstallDBG=0', 'OptionsInstallPy=0', 'OptionsInstallAndroidPlatform=0') } default { Write-Host "::error::windows-minimal must be true or false, got '$env:WINDOWS_MINIMAL'"; exit 1 } }